Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e22970f1ef | |||
| 020c860cc5 | |||
| 2f373784bf | |||
| b4845564af | |||
| 838342649c | |||
| 7f604f5df6 | |||
| 099b7a263f | |||
| a0776f79b1 | |||
| 4b484f899a | |||
| c2256582cd | |||
| b405e54bb0 | |||
| 5c97913868 | |||
| c8fc3e7a09 | |||
| 7c2daa1e32 | |||
| 37919cfe17 | |||
| 9081a9916c | |||
| 5430d4c3ab | |||
| fc4ea17789 | |||
| 02bcc8b4af | |||
| d4d0bde0c0 | |||
| cbbab3d09f | |||
| 36605751cd | |||
| 4d9a07727a | |||
| 513dee8fa9 | |||
| 19ddf86c7b | |||
| 28be65f092 | |||
| 65913de2cb | |||
| 25f1fd27d9 | |||
| 9a0d6496c5 | |||
| 7669a1bb56 | |||
| ddee605874 | |||
| ae8118d7df | |||
| 739c94d3bb | |||
| 3388169329 | |||
| 0e07e1d1ad | |||
| 83f37bada6 | |||
| d989d07fe7 | |||
| fa54c58bc4 | |||
| dd304913df | |||
| 48ad6b6618 | |||
| 81ac4a5383 | |||
| f51ee7b234 | |||
| d593b61af8 | |||
| fc87b13e5b | |||
| 560470b86b | |||
| 1fded5ff16 | |||
| 65585c61ad | |||
| 0583a8ffae | |||
| 4d9d710a02 | |||
| 3fbee9ce30 | |||
| fd98f46267 | |||
| b2581024f3 | |||
| 07044f439c | |||
| c60d465711 | |||
| ce2b29f5df | |||
| 4cb4212829 | |||
| fce0266b47 | |||
| 8d80f95bd0 | |||
| 48f0907f78 | |||
| dda6a25439 | |||
| 1aad3251ba | |||
| b26200f948 | |||
| ea8d8eee9a | |||
| adbcb8f59a | |||
| 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 | |||
| ea1014285d | |||
| 4f849a23b8 | |||
| 2c2f0b592a | |||
| 299f6bfea7 | |||
| 8c0eb3dfab | |||
| a39e04329e | |||
| 6f27e775f2 | |||
| 358bbc7eb5 | |||
| 444f8d7e33 | |||
| a80547c514 | |||
| d87397b382 | |||
| 018b69285d | |||
| a4ad848c93 | |||
| ff8c00d2f4 | |||
| abf1312cf5 | |||
| 8b09c51271 | |||
| f61573513c | |||
| 757c35e4a0 | |||
| 113a933170 | |||
| 18bb1fa0be | |||
| 38b81a4004 | |||
| ae7af9adf4 | |||
| c0cd7c2c15 | |||
| ac002d8255 | |||
| 0fc1fa139e | |||
| 4f0c5bb808 | |||
| a6b22df666 | |||
| b402c01918 | |||
| be478acde7 | |||
| 379873765d | |||
| 713a292057 | |||
| 58c2dfd0a9 | |||
| 710555bd7e | |||
| 42a5f3bc3b | |||
| 19647b5209 | |||
| d8a255869c | |||
| f0336d784d | |||
| a218999243 | |||
| 55fa7df2bf | |||
| b2341c652b | |||
| 021c5d6ad8 | |||
| d1264a7797 | |||
| 7a5f03987d | |||
| 8eb316751d | |||
| d0c1db6c1d | |||
| f6e57b759e | |||
| c9adcaa5e4 | |||
| 0d5204b5ec | |||
| 15bb136c79 | |||
| 16a1139eab | |||
| 710cabcf0d | |||
| 5acd8e4cd0 | |||
| 652c9290b9 | |||
| beddbcf94d | |||
| 3c6b6e8c22 | |||
| dbe2addc30 | |||
| c286593415 | |||
| ddba5c0b26 | |||
| 5b37f35eb8 | |||
| d1e87765af | |||
| b3b53c4adf | |||
| ba76936aba | |||
| 59ba7ba4c3 | |||
| 1ca1efb3b6 | |||
| c5ad487256 | |||
| 8274581edf | |||
| 704e70f60a | |||
| b81b314197 | |||
| a2375d2fd9 | |||
| 3f5e4f4290 | |||
| 0797e9a993 | |||
| c53b42342b | |||
| 123aa6d099 | |||
| 331d932c4b | |||
| 716e5f04cf | |||
| ef9d914b99 | |||
| 005efa2ee4 | |||
| 1190ed3ce6 | |||
| 2869e1c34e |
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
# Workspace gate: the same clippy + test commands CLAUDE.md §6 requires
|
||||||
|
# locally, run on every master push and pull request. Until this workflow
|
||||||
|
# existed, nothing in CI ran the test suite at all — a direct push to
|
||||||
|
# master was entirely unguarded.
|
||||||
|
#
|
||||||
|
# Build caching (2026-07-13): the Gitea actions cache never restored on
|
||||||
|
# this instance — every run back through run 597 logged "No cache found"
|
||||||
|
# even for exact keys saved successfully ("Cache saved successfully") by
|
||||||
|
# a run an hour earlier, including master→master restores. Until the
|
||||||
|
# cache server on the runner host is fixed, Swatinem/rust-cache is pure
|
||||||
|
# overhead here. `rust-host` is a HOST executor (its filesystem persists
|
||||||
|
# between runs — ~/.cargo and the rustup toolchain already carry over),
|
||||||
|
# so we get warm builds by pointing CARGO_TARGET_DIR at a persistent
|
||||||
|
# path on the runner instead of tarring gigabytes through a cache API
|
||||||
|
# that never returns them. Concurrent runs are safe: cargo serialises
|
||||||
|
# on the target-dir lock.
|
||||||
|
|
||||||
|
name: Test
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [master]
|
||||||
|
paths:
|
||||||
|
- 'solitaire_app/**'
|
||||||
|
- 'solitaire_assetgen/**'
|
||||||
|
- 'solitaire_core/**'
|
||||||
|
- 'solitaire_data/**'
|
||||||
|
- 'solitaire_engine/**'
|
||||||
|
- 'solitaire_server/src/**'
|
||||||
|
- 'solitaire_server/tests/**'
|
||||||
|
- 'solitaire_server/migrations/**'
|
||||||
|
- 'solitaire_sync/**'
|
||||||
|
- 'solitaire_wasm/**'
|
||||||
|
- 'solitaire_web/**'
|
||||||
|
- 'Cargo.toml'
|
||||||
|
- 'Cargo.lock'
|
||||||
|
- '.cargo/**'
|
||||||
|
- '.sqlx/**'
|
||||||
|
- '.gitea/workflows/test.yml'
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
# Seconds-long formatting gate in its own job so a rustfmt slip fails
|
||||||
|
# here instead of after a 35-minute cold build (run 600 spent its
|
||||||
|
# whole build budget to report an unformatted file).
|
||||||
|
fmt:
|
||||||
|
runs-on: rust-host
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust 1.95.0
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.95.0
|
||||||
|
components: rustfmt
|
||||||
|
|
||||||
|
- name: Format check
|
||||||
|
run: cargo fmt --check
|
||||||
|
|
||||||
|
test:
|
||||||
|
runs-on: rust-host
|
||||||
|
needs: fmt
|
||||||
|
|
||||||
|
# Full debuginfo made the solitaire_engine test-binary link peak past the
|
||||||
|
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
|
||||||
|
# line-tables-only keeps file:line in panic backtraces while cutting the
|
||||||
|
# link's memory footprint enough to fit the runner.
|
||||||
|
#
|
||||||
|
# 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 warm build.
|
||||||
|
#
|
||||||
|
# CARGO_INCREMENTAL=0: incremental artifacts bloat the persistent
|
||||||
|
# target dir for little benefit in CI (rust-cache used to set this
|
||||||
|
# for the same reason).
|
||||||
|
#
|
||||||
|
# CARGO_TARGET_DIR: persistent on the runner host — see the header
|
||||||
|
# comment. The prune step below keeps it from growing unbounded.
|
||||||
|
env:
|
||||||
|
CARGO_PROFILE_DEV_DEBUG: line-tables-only
|
||||||
|
CARGO_BUILD_JOBS: '2'
|
||||||
|
CARGO_INCREMENTAL: '0'
|
||||||
|
CARGO_TARGET_DIR: /home/runner/.cache/ferrous-solitaire/target
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust 1.95.0
|
||||||
|
uses: dtolnay/rust-toolchain@master
|
||||||
|
with:
|
||||||
|
toolchain: 1.95.0
|
||||||
|
components: clippy
|
||||||
|
|
||||||
|
# Toolchain or lockfile bumps strand stale artifacts nothing will
|
||||||
|
# ever reuse; reset the dir when it crosses 40 GiB rather than
|
||||||
|
# curating it (a cold rebuild every few weeks is cheaper than the
|
||||||
|
# bookkeeping).
|
||||||
|
- name: Prune persistent target dir when oversized
|
||||||
|
run: |
|
||||||
|
limit_kb=$((40 * 1024 * 1024))
|
||||||
|
used_kb=$(du -sk "$CARGO_TARGET_DIR" 2>/dev/null | cut -f1 || echo 0)
|
||||||
|
echo "persistent target dir: $((used_kb / 1024)) MiB (limit $((limit_kb / 1024)) MiB)"
|
||||||
|
if [ "${used_kb:-0}" -gt "$limit_kb" ]; then
|
||||||
|
echo "over limit — clearing for a fresh cold build"
|
||||||
|
rm -rf "$CARGO_TARGET_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Native link deps for the Bevy crates (engine/app/web) on a bare
|
||||||
|
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
|
||||||
|
- name: Install Bevy native dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
|
||||||
|
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
|
||||||
|
|
||||||
|
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
|
||||||
|
# same as the web-e2e workflow's server prebuild.
|
||||||
|
- name: Clippy (deny warnings)
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: 'true'
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
env:
|
||||||
|
SQLX_OFFLINE: 'true'
|
||||||
|
run: cargo test --workspace
|
||||||
@@ -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/
|
||||||
|
|||||||
+92
-46
@@ -1,9 +1,11 @@
|
|||||||
# Ferrous Solitaire — Architecture Document
|
# Ferrous Solitaire — Architecture Document
|
||||||
|
|
||||||
> **Version:** 1.3
|
> **Version:** 1.4
|
||||||
> **Language:** Rust (Edition 2024)
|
> **Language:** Rust (Edition 2024)
|
||||||
> **Engine:** Bevy (latest stable)
|
> **Engine:** Bevy (latest stable)
|
||||||
> **Last Updated:** 2026-05-12
|
> **Last Updated:** 2026-07-06 — post card_game/klondike migration (PR #88):
|
||||||
|
> core card/pile types come from the upstream `card_game` workspace and
|
||||||
|
> score/undo/recycle are derived from the upstream session, not stored.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -82,13 +84,15 @@ ferrous_solitaire/
|
|||||||
│ ├── win_fanfare.wav
|
│ ├── win_fanfare.wav
|
||||||
│ └── ambient_loop.wav
|
│ └── ambient_loop.wav
|
||||||
│
|
│
|
||||||
├── solitaire_core/ # Pure Rust game logic — zero external deps beyond rand/serde
|
├── solitaire_core/ # Pure Rust game rules — wraps upstream card_game/klondike (serde + thiserror only otherwise)
|
||||||
├── solitaire_sync/ # Shared API types — used by client and server
|
├── solitaire_sync/ # Shared API types — used by client and server
|
||||||
├── solitaire_data/ # Persistence, sync client, settings
|
├── solitaire_data/ # Persistence, sync client, settings
|
||||||
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
├── solitaire_engine/ # Bevy ECS systems, components, plugins
|
||||||
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite)
|
├── solitaire_server/ # Self-hosted sync server (Axum + SQLite) + web frontend
|
||||||
├── solitaire_wasm/ # WebAssembly bindings — browser-side replay player
|
├── solitaire_wasm/ # WebAssembly bindings — browser-side logic/replay + debug bridge
|
||||||
└── solitaire_app/ # Main binary entry point
|
├── solitaire_web/ # Bevy WASM canvas build for the browser /play route
|
||||||
|
├── solitaire_assetgen/ # One-shot generator for card/background PNG assets
|
||||||
|
└── solitaire_app/ # Main binary entry point (desktop + Android cdylib)
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -96,18 +100,42 @@ ferrous_solitaire/
|
|||||||
## 3. Crate Responsibilities
|
## 3. Crate Responsibilities
|
||||||
|
|
||||||
### `solitaire_core`
|
### `solitaire_core`
|
||||||
**Dependencies:** `rand`, `serde`, `chrono` only.
|
**Dependencies:** `serde`, `thiserror`, plus the upstream `card_game` and
|
||||||
|
`klondike` crates (pinned via the Quaternions registry — never edit upstream).
|
||||||
|
|
||||||
The entire game rules engine. No Bevy, no network, no file I/O. Designed to be tested in isolation with `cargo test -p solitaire_core`.
|
The game rules layer. No Bevy, no network, no file I/O. Designed to be tested
|
||||||
|
in isolation with `cargo test -p solitaire_core`.
|
||||||
|
|
||||||
|
Since the card_game migration (2026-06-22, PR #88) the primitive types are
|
||||||
|
**upstream**: `Card`, `Deck`, `Suit`, `Rank`, `Session` come from `card_game`;
|
||||||
|
`Klondike`, `KlondikePile`, `KlondikeInstruction`, `DrawStockConfig`,
|
||||||
|
`Foundation`, `Tableau` come from `klondike`. `solitaire_core` re-exports them
|
||||||
|
so downstream crates import from one place and never depend on the upstream
|
||||||
|
crates directly.
|
||||||
|
|
||||||
Owns:
|
Owns:
|
||||||
- All game data models (`Card`, `Suit`, `Rank`, `Pile`, `GameState`)
|
- `GameState` — a wrapper around the upstream `Session<Klondike>`; the session
|
||||||
- Move validation logic
|
is the single source of truth for board state and stats
|
||||||
- Scoring engine
|
- `MoveError` and the `Result`-based mutation API
|
||||||
- Undo stack
|
- `KlondikeConfig` adaptation (`klondike_adapter`) — draw mode, scoring,
|
||||||
|
take-from-foundation
|
||||||
|
- `GameMode` (Classic / Zen / Challenge / TimeAttack) and mode-aware scoring
|
||||||
|
- Solvability check API (`SolveOutcome`, delegating to `Session::solve`)
|
||||||
- Win / auto-complete detection
|
- Win / auto-complete detection
|
||||||
- Achievement unlock condition evaluation
|
- Achievement unlock condition evaluation
|
||||||
- Seeded RNG for reproducible deals
|
- Seeded deals (same seed ⇒ same layout, via the upstream dealer)
|
||||||
|
|
||||||
|
**Rules decisions:**
|
||||||
|
- **Stock recycling is unlimited in every draw mode — by design.** Extra
|
||||||
|
passes through the stock are discouraged via the upstream score penalty
|
||||||
|
(applied by the `card_game`/`klondike` session), never blocked with a
|
||||||
|
`MoveError`. This matches mainstream digital solitaire (unlimited redeals
|
||||||
|
in Draw-1) rather than strict tournament rules (3-pass cap). It is load-
|
||||||
|
bearing: the difficulty seed catalog and the winnable-deal solver are
|
||||||
|
verified under unlimited recycling, so introducing a hard pass limit would
|
||||||
|
invalidate both. Locked in by the
|
||||||
|
`draw_one_recycling_is_unlimited_by_design` test in
|
||||||
|
`solitaire_core/src/game_state.rs`. (Decision record: Gitea issue #117.)
|
||||||
|
|
||||||
### `solitaire_sync`
|
### `solitaire_sync`
|
||||||
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
**Dependencies:** `serde`, `serde_json`, `uuid`, `chrono` only.
|
||||||
@@ -117,7 +145,6 @@ Shared API contract types imported by both the game client (`solitaire_data`) an
|
|||||||
Owns:
|
Owns:
|
||||||
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
- `SyncPayload`, `SyncResponse`, `ConflictReport`
|
||||||
- `ChallengeGoal`, `LeaderboardEntry`
|
- `ChallengeGoal`, `LeaderboardEntry`
|
||||||
- `ApiError` enum
|
|
||||||
- Merge logic (pure functions, no I/O)
|
- Merge logic (pure functions, no I/O)
|
||||||
|
|
||||||
### `solitaire_data`
|
### `solitaire_data`
|
||||||
@@ -165,7 +192,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.
|
||||||
|
|
||||||
@@ -177,9 +204,13 @@ Owns:
|
|||||||
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
Because `ReplayPlayer` uses the same `solitaire_core::GameState` as the desktop client, the two implementations cannot drift: the same seed + move list produces identical pile state at every step on both platforms.
|
||||||
|
|
||||||
### `solitaire_app`
|
### `solitaire_app`
|
||||||
**Dependencies:** `bevy`, `solitaire_engine`.
|
**Dependencies:** `bevy`, `solitaire_engine`, `solitaire_data` (+ `jni` on Android).
|
||||||
|
|
||||||
Thin binary entry point. Registers all Bevy plugins and sets initial window properties.
|
Thin entry point (desktop binary + Android `cdylib`). Registers all Bevy
|
||||||
|
plugins and sets initial window properties. The one crate in the workspace
|
||||||
|
allowed `unsafe`: the Android entry point reconstructs the raw JNI handles and
|
||||||
|
hands them to the safe `solitaire_data::android_jni` bridge; everything else
|
||||||
|
is `forbid(unsafe_code)`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -225,7 +256,7 @@ solitaire_sync::merge(local, remote)
|
|||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
Write merged result to disk
|
Write merged result to disk
|
||||||
│ fires SyncCompleteEvent
|
│
|
||||||
▼
|
▼
|
||||||
Bevy main thread reads updated StatsResource
|
Bevy main thread reads updated StatsResource
|
||||||
```
|
```
|
||||||
@@ -345,7 +376,6 @@ struct StateChangedEvent;
|
|||||||
struct CardFlippedEvent(u32);
|
struct CardFlippedEvent(u32);
|
||||||
struct GameWonEvent { score: i32, time_seconds: u64 }
|
struct GameWonEvent { score: i32, time_seconds: u64 }
|
||||||
struct AchievementUnlockedEvent(AchievementRecord);
|
struct AchievementUnlockedEvent(AchievementRecord);
|
||||||
struct SyncCompleteEvent(Result<SyncResponse, String>);
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Layout System
|
### Layout System
|
||||||
@@ -547,26 +577,36 @@ This ensures all players worldwide get the same challenge for a given date, rega
|
|||||||
|
|
||||||
### Core Game Models (`solitaire_core`)
|
### Core Game Models (`solitaire_core`)
|
||||||
|
|
||||||
|
Since the card_game migration, the primitives are upstream types re-exported
|
||||||
|
through `solitaire_core`:
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
pub enum Suit { Clubs, Diamonds, Hearts, Spades }
|
// From `card_game` (upstream — never edit):
|
||||||
pub enum Rank { Ace, Two, Three, Four, Five, Six, Seven, Eight, Nine, Ten, Jack, Queen, King }
|
pub enum Suit { /* Clubs, Diamonds, Hearts, Spades */ }
|
||||||
|
pub enum Rank { /* Ace ..= King */ }
|
||||||
|
pub struct Card { /* deck + suit + rank; identity type, no face_up flag —
|
||||||
|
facing is positional, tracked by the Klondike board */ }
|
||||||
|
pub struct Session<G> { /* replayable instruction log + derived stats */ }
|
||||||
|
|
||||||
pub struct Card {
|
// From `klondike` (upstream — never edit):
|
||||||
pub id: u32,
|
pub enum KlondikePile {
|
||||||
pub suit: Suit,
|
Stock, // NB: no Waste variant — see below
|
||||||
pub rank: Rank,
|
Foundation(Foundation), // 4 slots, any suit may claim any slot
|
||||||
pub face_up: bool,
|
Tableau(Tableau), // 7 columns
|
||||||
}
|
}
|
||||||
|
// Pile-coordinate convention: upstream has no `Waste` variant. In
|
||||||
|
// pile-coordinate space `KlondikePile::Stock` denotes the face-up
|
||||||
|
// *waste* pile (the only stock-side pile cards move out of); use
|
||||||
|
// `GameState::stock_cards()` / `waste_cards()` when the face-down
|
||||||
|
// draw stack must be distinguished. Documented on `GameState::pile`.
|
||||||
|
pub enum DrawStockConfig { DrawOne, DrawThree }
|
||||||
|
pub enum KlondikeInstruction { /* RotateStock, DstFoundation, ... — the
|
||||||
|
serialized move format (schema v4+) */ }
|
||||||
|
```
|
||||||
|
|
||||||
pub enum PileType {
|
Owned by `solitaire_core`:
|
||||||
Stock,
|
|
||||||
Waste,
|
|
||||||
Foundation(Suit),
|
|
||||||
Tableau(usize), // 0–6
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum DrawMode { DrawOne, DrawThree }
|
|
||||||
|
|
||||||
|
```rust
|
||||||
/// Active game mode. Classic is the default; others unlock at level 5.
|
/// Active game mode. Classic is the default; others unlock at level 5.
|
||||||
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
pub enum GameMode { Classic, Zen, Challenge, TimeAttack }
|
||||||
|
|
||||||
@@ -577,24 +617,30 @@ pub enum MoveError {
|
|||||||
RuleViolation(String),
|
RuleViolation(String),
|
||||||
UndoStackEmpty,
|
UndoStackEmpty,
|
||||||
GameAlreadyWon,
|
GameAlreadyWon,
|
||||||
|
StockEmpty,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct GameState {
|
pub struct GameState {
|
||||||
pub piles: HashMap<PileType, Vec<Card>>,
|
|
||||||
pub draw_mode: DrawMode,
|
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
pub score: i32,
|
|
||||||
pub move_count: u32,
|
|
||||||
pub undo_count: u32, // number of undos used in this game
|
|
||||||
pub recycle_count: u32, // number of stock recycles
|
|
||||||
pub elapsed_seconds: u64,
|
pub elapsed_seconds: u64,
|
||||||
pub seed: u64,
|
pub seed: u64, // same seed ⇒ same deal
|
||||||
pub is_won: bool,
|
pub take_from_foundation: bool,
|
||||||
pub is_auto_completable: bool,
|
session: Session<Klondike>, // private — the single source of truth
|
||||||
undo_stack: VecDeque<StateSnapshot>, // private, max 64 (VecDeque for O(1) pop_front)
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Derived, not stored:** `score()`, `move_count()`, `undo_count()`,
|
||||||
|
`recycle_count()`, `is_won()`, `is_auto_completable()`, and all pile
|
||||||
|
accessors read through the session. Undo replays the instruction log
|
||||||
|
(no snapshot stack); the −15 undo penalty is applied by the upstream
|
||||||
|
score formula via the session config. Persistence (schema v5) saves
|
||||||
|
`saved_moves` as upstream `KlondikeInstruction`s and rebuilds the
|
||||||
|
session by replay on load — older files carrying `score`/`undo_count`/
|
||||||
|
`recycle_count` keys load fine, the extra fields are ignored.
|
||||||
|
|
||||||
|
**Rules decision:** stock recycling is unlimited in every draw mode
|
||||||
|
(see the "Rules decisions" note in §3 `solitaire_core`).
|
||||||
|
|
||||||
### Persistence Models (`solitaire_data`)
|
### Persistence Models (`solitaire_data`)
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -632,7 +678,7 @@ pub struct AchievementRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Settings {
|
pub struct Settings {
|
||||||
pub draw_mode: DrawMode,
|
pub draw_mode: DrawStockConfig,
|
||||||
pub sfx_volume: f32, // 0.0–1.0
|
pub sfx_volume: f32, // 0.0–1.0
|
||||||
pub music_volume: f32,
|
pub music_volume: f32,
|
||||||
pub animation_speed: AnimSpeed,
|
pub animation_speed: AnimSpeed,
|
||||||
@@ -697,7 +743,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
|
||||||
|
|
||||||
|
|||||||
+252
@@ -6,8 +6,250 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.49.0] — 2026-07-16
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **See what sync did.** After every sync, the Account tab shows exactly
|
||||||
|
what the merge decided — a "no conflicts" note, or one line per
|
||||||
|
reconciled field with both values ("this device: 3 / server: 5"). When
|
||||||
|
a merge wasn't clean, a toast summarises it: "Synced — 2 conflicts,
|
||||||
|
kept newer values". (#189)
|
||||||
|
- **One-file backup.** Settings → Account → Local data can now export
|
||||||
|
every save (settings, stats, achievements, progress) into a single
|
||||||
|
backup file next to your other saves — and restore from it later or on
|
||||||
|
another machine. Restores never silently reset anything: a missing or
|
||||||
|
incompatible backup shows an error and leaves your data untouched.
|
||||||
|
(#189)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Under the hood, the scheduler-ambiguity gate now covers the full
|
||||||
|
plugin cluster (22 plugins) — 585 undefined-order system pairs were
|
||||||
|
pinned or proven independent, eliminating a whole class of
|
||||||
|
"works-on-my-frame" bugs in modes, replays, input, and chrome. (#188)
|
||||||
|
|
||||||
|
## [0.48.1] — 2026-07-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Reduce Transparency.** Settings → Accessibility gains a toggle that
|
||||||
|
replaces the see-through glass toolbar with a solid panel, so cards
|
||||||
|
behind it can never reduce readability. High Contrast mode now also
|
||||||
|
makes the glass markedly more opaque with a brighter outline. (#187)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The toolbar gets out of your way.** While you're dragging a card,
|
||||||
|
the floating toolbar slides off the bottom edge and returns when you
|
||||||
|
drop — bottom-row piles are never hidden behind it. Instant (no
|
||||||
|
animation) when Reduce Motion is on. (#187)
|
||||||
|
|
||||||
|
## [0.48.0] — 2026-07-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Floating glass tab bar** (touch). The bottom action bar is now a
|
||||||
|
floating translucent pill — Undo, Draw, Hint, and Pause — with a
|
||||||
|
separate round Menu button beside it, hovering above the bottom edge
|
||||||
|
instead of docking to it. Draw stays expanded as the highlighted
|
||||||
|
action; the other buttons reveal their label while held (instantly,
|
||||||
|
with reduce-motion on). Same actions, same behaviour — new clothes.
|
||||||
|
(#186)
|
||||||
|
- **Consistent empty, loading, and error screens.** The theme store,
|
||||||
|
leaderboard, and replay list now share one visual language for
|
||||||
|
"nothing here yet", "still loading", and "something went wrong" —
|
||||||
|
each with a next step instead of a dead end. (#184)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Hotkeys during drags.** Pressing Undo, New Game, Draw, or a mode
|
||||||
|
key mid-drag no longer fights the card you're holding — game-mutating
|
||||||
|
hotkeys are ignored until the card is dropped. (#185)
|
||||||
|
|
||||||
|
## [0.47.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **"What's new" on update.** The first launch after an update shows a
|
||||||
|
one-time card summarising what changed — like this one, right now.
|
||||||
|
Dismiss it and it never repeats; fresh installs never see it. (#180)
|
||||||
|
- **Situational tips.** Two one-time teaches that fire from play, not a
|
||||||
|
tour: staring at a stuck board for a while points you at Hint, and an
|
||||||
|
engaged game quietly mentions the long-press quick-action menu —
|
||||||
|
unless you've already found it yourself. (#181)
|
||||||
|
- **UI scale.** Settings → Accessibility now has a UI Scale control
|
||||||
|
(90% / 100% / 115% / 130%) that resizes every menu, button, and HUD
|
||||||
|
element live. The table always fits your screen regardless. (#182)
|
||||||
|
- **Hold `/` for shortcuts** (desktop). A reference card of every
|
||||||
|
keyboard binding appears while `/` is held. The onboarding tour and
|
||||||
|
this card now share one binding table, so they can never disagree —
|
||||||
|
the tour's copy had drifted from reality. (#183)
|
||||||
|
|
||||||
|
## [0.46.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Theme-store previews.** The store modal now shows each theme's
|
||||||
|
preview image next to its name — the server has advertised them since
|
||||||
|
the store launched; the client finally fetches them. Thumbnails load
|
||||||
|
in the background, pop in as they arrive, and are remembered for the
|
||||||
|
session. (#179)
|
||||||
|
- **Hint ghost preview.** Asking for a hint now also plays a translucent
|
||||||
|
copy of the suggested card gliding to its destination (twice, then it
|
||||||
|
fades) alongside the usual highlights — you see the move, not just the
|
||||||
|
pieces. Automatically disabled when reduce-motion is on. (#179)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **One toast style, one place.** Queued info banners and instant
|
||||||
|
celebration/warning/error toasts now share a single bottom-anchored
|
||||||
|
stack that clears the touch action bar; simultaneous toasts stack
|
||||||
|
upward instead of overlapping. (#178)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
|
||||||
|
players to left/right-click; it now describes drag, double-tap, and
|
||||||
|
the bottom-bar Hint button. (#178)
|
||||||
|
- **First launch shows one screen at a time.** On a fresh install the
|
||||||
|
Home screen no longer spawns underneath the welcome tour; the order is
|
||||||
|
now onboarding → Home → table. (#178)
|
||||||
|
|
||||||
|
## [0.45.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Thumb-reach action bar on touch (menu redesign Phase F).** The bottom
|
||||||
|
bar slims to five buttons: an enlarged **Undo · Draw · Hint** trio
|
||||||
|
(96×64 px targets) between compact Menu and Pause. **Draw is new** — it
|
||||||
|
draws from the stock exactly like tapping the deck, without the reach to
|
||||||
|
the top of a tall folded screen. Help, Modes, and New Game moved off the
|
||||||
|
touch bar (Menu → System, the Home grid, and Home's hero button cover
|
||||||
|
them). Desktop keeps its seven-button bar unchanged.
|
||||||
|
- **Hold-to-repeat Undo.** Press and hold Undo to step back repeatedly
|
||||||
|
(~5.5 undos/s after a short delay) instead of tap-tap-tap. Every step
|
||||||
|
goes through the normal undo path, so scoring penalties apply as usual.
|
||||||
|
|
||||||
|
## [0.44.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Home is now a real home (menu redesign Phase B).** The mode picker became
|
||||||
|
a hierarchy: a **Continue** card (mode · elapsed · score) appears while a
|
||||||
|
game is in progress and returns to the table; a hero **New Game** button
|
||||||
|
replays your last mode with the current deal options in one tap; deal
|
||||||
|
options (draw 1/3, a new **winnable-only** toggle, difficulty tiers) moved
|
||||||
|
into a disclosure under the hero; the six modes sit in a compact symmetric
|
||||||
|
2×3 grid with descriptions on wide screens; and the stats strip moved to
|
||||||
|
the bottom. Wide viewports (desktop, unfolded foldables) get a two-pane
|
||||||
|
layout — launch surfaces left, Continue/daily/stats right. Cancel is now
|
||||||
|
**Back to table** and only appears while a live game exists. (#175)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Save files are self-contained (schema v6).** Like replays in 0.43.3, a
|
||||||
|
saved game now stores the dealt board via the upstream session serializers
|
||||||
|
instead of re-dealing from the seed on load; v4/v5 saves still load through
|
||||||
|
the legacy path and rewrite as v6 on next save. (#171)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **CI is ~9× faster.** Root-caused why the actions cache never restored on
|
||||||
|
the host-executor runner (per-run workdir paths poisoned the cache version
|
||||||
|
hash); the test workflow now uses a persistent on-runner target dir, and a
|
||||||
|
seconds-long `fmt` gate fails formatting mistakes before the build. Warm
|
||||||
|
full-gate runs: 4m29s, down from ~40 min. (#176)
|
||||||
|
|
||||||
|
## [0.43.3] — 2026-07-10
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Replays are now self-contained (schema v4).** A replay stores the dealt
|
||||||
|
board itself via the upstream `card_game` session serializers instead of
|
||||||
|
re-dealing from the seed at playback time, so replays survive RNG and
|
||||||
|
upstream upgrades that change the seed→deal mapping — the failure that had
|
||||||
|
silently broken every stored replay. The web player and web game now
|
||||||
|
exchange the full payload through the wasm layer (the old JS path hardcoded
|
||||||
|
`schema_version: 2`, uploaded empty move lists, and corrupted u64 seeds via
|
||||||
|
`Math.round`), and the replay viewer reports unplayable old-format replays
|
||||||
|
in the caption instead of dying silently. Pre-v4 replays are rejected by a
|
||||||
|
version gate; local histories repopulate with new wins. (#170)
|
||||||
|
- **Difficulty-mode wins can upload.** The server's replay `mode` validation
|
||||||
|
now accepts data-carrying `GameMode` variants (previously a 400). (#170)
|
||||||
|
|
||||||
|
## [0.42.0] — 2026-07-06
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **CI workspace gate.** New `test.yml` workflow runs clippy (deny warnings)
|
||||||
|
and the full test suite on every master push and PR — previously no CI ran
|
||||||
|
tests at all. Caught its own first bug (missing Bevy native deps) on its
|
||||||
|
own PR. (#135)
|
||||||
|
- **Schedule ambiguity gate.** A headless test builds the gameplay plugin
|
||||||
|
cluster with Bevy ambiguity detection promoted to error. The initial
|
||||||
|
measurement found 302 system pairs with conflicting data access and no
|
||||||
|
ordering; four burn-down batches (PRs #146–#149) took it to ZERO the same
|
||||||
|
day, and the gate now enforces 0. Keyboard consumption, board painting,
|
||||||
|
and HUD updates all have deterministic order for the first time.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Browser canvas 36% smaller.** `canvas_bg.wasm` shrank 36.2 MB → 23.2 MB
|
||||||
|
via a size-focused `wasm-release` profile (fat LTO, single codegen unit,
|
||||||
|
opt-level "s"); verified visually identical in production. (#134)
|
||||||
|
- **Quaternions API adoption.** Canonical `FOUNDATIONS`/`TABLEAUS` consts in
|
||||||
|
`solitaire_core` replace five scattered enum lists; upstream
|
||||||
|
`Suit::SUITS`/`Rank::RANKS` replace nine hand-rolled arrays, with the
|
||||||
|
texture-atlas indexing re-keyed through tested canonical helpers. Net
|
||||||
|
−177 lines. (#137)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Sync push race.** The server's load→merge→store cycle now runs in one
|
||||||
|
transaction; concurrent pushes from two devices can no longer overwrite
|
||||||
|
each other's merge. (#136)
|
||||||
|
- **Refresh-token rotation is single-use under concurrency** — rotation
|
||||||
|
gates on the DELETE's row count, so a stolen-then-replayed refresh token
|
||||||
|
loses the race and gets 401. (#136)
|
||||||
|
- **Exit sync push actually completes.** Was a detached task killed by
|
||||||
|
process teardown; now a bounded 2-second blocking wait on the app's final
|
||||||
|
frame. (#138)
|
||||||
|
- **Server auth hardening.** Login timing no longer reveals whether a
|
||||||
|
username exists; concurrent duplicate registration returns 409 instead of
|
||||||
|
500; avatar uploads are magic-byte checked. (#144, issues #139–#141)
|
||||||
|
|
||||||
|
## [0.41.1] — 2026-07-06
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Oversized pile-marker frames after fold/unfold.** `on_window_resized`
|
||||||
|
resized the marker fill sprite but never its children, so the outline frame
|
||||||
|
and the "A"/"K" watermark kept their spawn-time size after any resize —
|
||||||
|
rendering as oversized grey slabs over empty foundation slots on foldables
|
||||||
|
(found during Galaxy Fold 7 on-device verification of v0.41.0, fixed and
|
||||||
|
re-verified on the same device). Both children are now re-derived from the
|
||||||
|
new layout on every relayout.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Android relayout diagnostics.** Every layout recompute on Android now
|
||||||
|
logs its window dimensions and insets (`layout: resize to WxH …`) — the
|
||||||
|
evidence channel for foldable layout reports (#130).
|
||||||
|
|
||||||
|
## [0.41.0] — 2026-07-06
|
||||||
|
|
||||||
|
> Consolidates everything shipped since v0.39.0, including the v0.40.0–v0.40.3
|
||||||
|
> patch tags (which were cut from this section without renaming it at the time).
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Rules decision record: unlimited stock recycling.** Documented in
|
||||||
|
`ARCHITECTURE.md` that unlimited recycling with score penalties (matching
|
||||||
|
mainstream digital solitaire) is intentional, and locked it in with a core
|
||||||
|
test — the difficulty seed catalog and winnable-deal solver are verified
|
||||||
|
under this rule. Resolves the last open finding from the June 500-game
|
||||||
|
audit (issue #117).
|
||||||
- **Analytics validation runbook.** Documented native Matomo live validation,
|
- **Analytics validation runbook.** Documented native Matomo live validation,
|
||||||
expected event payloads, and the current web/WASM analytics split.
|
expected event payloads, and the current web/WASM analytics split.
|
||||||
- **Android smoke-test runbook.** Updated the Android doc with the current
|
- **Android smoke-test runbook.** Updated the Android doc with the current
|
||||||
@@ -26,6 +268,10 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- **Engine plugin modules restructured.** The five oversized plugin files
|
||||||
|
(card, hud, settings, game, input) are now module directories with their
|
||||||
|
test suites in sibling `tests.rs` files — no behaviour change; first phase
|
||||||
|
of the module-split plan (#118).
|
||||||
- **Core type ownership.** Routed all klondike/card imports through
|
- **Core type ownership.** Routed all klondike/card imports through
|
||||||
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
|
`solitaire_core` and unified local `Suit` / `Rank` with upstream `card_game`
|
||||||
types.
|
types.
|
||||||
@@ -37,6 +283,12 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
- **Safe-area insets now re-polled after app resume.** The inset poller settled
|
||||||
|
permanently once insets first resolved, so inset changes while backgrounded
|
||||||
|
(fold/unfold, rotation, gesture ↔ 3-button nav switch) kept stale layout until
|
||||||
|
the app was killed. Each resume now re-arms a fresh poll window; the cached
|
||||||
|
value is rewritten only when it actually changed, so unchanged resumes still
|
||||||
|
cause no relayout flash. (#116)
|
||||||
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
|
- **Tableau fill on foldables / tall screens.** The tableau fan now spreads from
|
||||||
each column's total depth (face-down cards included) and refills on the
|
each column's total depth (face-down cards included) and refills on the
|
||||||
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
|
cold-start deal, every move, and on resize (incl. the Android safe-area-inset
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ solitaire_data/ # Persistence + sync client
|
|||||||
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
solitaire_engine/ # Bevy ECS + UI + gameplay orchestration
|
||||||
solitaire_server/ # Axum backend (optional sync layer)
|
solitaire_server/ # Axum backend (optional sync layer)
|
||||||
solitaire_wasm/ # WASM bindings for browser-side replay player
|
solitaire_wasm/ # WASM bindings for browser-side replay player
|
||||||
solitaire_app/ # Entry binary
|
solitaire_web/ # Bevy WASM canvas build for the browser /play route
|
||||||
|
solitaire_assetgen/ # One-shot card/background PNG asset generator
|
||||||
|
solitaire_app/ # Entry binary (desktop + Android cdylib)
|
||||||
assets/ # Runtime assets (except audio + default theme)
|
assets/ # Runtime assets (except audio + default theme)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Generated
+10
@@ -7331,7 +7331,9 @@ dependencies = [
|
|||||||
"card_game",
|
"card_game",
|
||||||
"klondike",
|
"klondike",
|
||||||
"proptest",
|
"proptest",
|
||||||
|
"rand 0.10.1",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -7351,13 +7353,16 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"solitaire_core",
|
"solitaire_core",
|
||||||
"solitaire_server",
|
"solitaire_server",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7402,10 +7407,13 @@ dependencies = [
|
|||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
|
"ron",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2",
|
||||||
"solitaire_sync",
|
"solitaire_sync",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
|
"tempfile",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower",
|
"tower",
|
||||||
@@ -7414,6 +7422,7 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
"uuid",
|
"uuid",
|
||||||
|
"zip",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -7422,6 +7431,7 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|||||||
+17
@@ -47,6 +47,7 @@ dirs = "6"
|
|||||||
keyring = "4"
|
keyring = "4"
|
||||||
keyring-core = "1"
|
keyring-core = "1"
|
||||||
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
reqwest = { version = "0.13", features = ["json", "rustls", "rustls-native-certs"], default-features = false }
|
||||||
|
sha2 = "0.10"
|
||||||
arboard = { version = "3", default-features = false }
|
arboard = { version = "3", default-features = false }
|
||||||
jni = { version = "0.21", default-features = false }
|
jni = { version = "0.21", default-features = false }
|
||||||
|
|
||||||
@@ -156,3 +157,19 @@ opt-level = 3
|
|||||||
[profile.release]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = "thin"
|
lto = "thin"
|
||||||
|
|
||||||
|
# Size-focused profile for the browser canvas build (solitaire_web →
|
||||||
|
# canvas_bg.wasm). Download size is the constraint on the web, not peak
|
||||||
|
# throughput: fat LTO + one codegen unit + opt-level "s" cut the Bevy wasm
|
||||||
|
# bundle substantially versus plain release. This is rustc-side sizing only —
|
||||||
|
# the binaryen pass in build_wasm.sh stays at wasm-opt -O2 because -Oz has
|
||||||
|
# miscompiled Bevy's render pipeline before (grey screen on first load).
|
||||||
|
[profile.wasm-release]
|
||||||
|
inherits = "release"
|
||||||
|
opt-level = "s"
|
||||||
|
lto = "fat"
|
||||||
|
codegen-units = 1
|
||||||
|
# No `strip`: on wasm it also removes the target_features custom section,
|
||||||
|
# which makes wasm-opt reject the module ("all used features should be
|
||||||
|
# allowed" on trunc_sat). wasm-opt drops the name section in its output
|
||||||
|
# anyway, so strip buys nothing here.
|
||||||
|
|||||||
@@ -44,6 +44,26 @@ docker compose up -d
|
|||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Theme store
|
||||||
|
|
||||||
|
The server can offer card-art themes for in-game download. Drop theme
|
||||||
|
`.zip` archives (the same format the game's Settings → Import accepts:
|
||||||
|
a `theme.ron` manifest plus 53 SVGs) into the directory named by
|
||||||
|
`THEME_STORE_DIR` (default: `theme_store/` next to the binary), then
|
||||||
|
restart the server — the catalog is scanned once at startup. An
|
||||||
|
optional `<theme-id>.png` in the same directory becomes the theme's
|
||||||
|
store preview.
|
||||||
|
|
||||||
|
Endpoints (public, no auth):
|
||||||
|
|
||||||
|
- `GET /api/themes` — catalog JSON (id, name, author, size, sha256)
|
||||||
|
- `GET /api/themes/<id>/download` — the archive
|
||||||
|
- `GET /api/themes/<id>/preview` — the preview PNG, if present
|
||||||
|
|
||||||
|
Archives that are oversized (> 20 MiB), unreadable, or have a
|
||||||
|
malformed `theme.ron` are skipped with a warning in the server log;
|
||||||
|
they never fail startup.
|
||||||
|
|
||||||
## Admin — Password Reset
|
## Admin — Password Reset
|
||||||
|
|
||||||
If a player loses access to their account, the server binary includes a
|
If a player loses access to their account, the server binary includes a
|
||||||
|
|||||||
+74
-30
@@ -1,16 +1,57 @@
|
|||||||
# Ferrous Solitaire — Session Handoff
|
# Ferrous Solitaire — Session Handoff
|
||||||
|
|
||||||
**Last updated:** 2026-06-25 — v0.40.0 released (Android APK published); physical-device gate remains.
|
**Last updated:** 2026-07-06 — v0.41.0 + v0.41.1 released and verified on a
|
||||||
|
physical Galaxy Fold 7; issue tracker empty except low-priority #130.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Current state
|
## Current state
|
||||||
|
|
||||||
- **Branch state:** `master` pushed to origin; latest commits are the Draw-Three waste fan fix, its regression tests, and the NDK doc update (PRs #105–#108).
|
- **Branch state:** `master` pushed to origin; latest work is the 2026-07-06
|
||||||
- **Latest tag:** `v0.40.0` (released — signed arm64-v8a APK published to the Gitea release for Obtainium/sideload). `v0.39.1` was the prior published release.
|
arc (PRs #121–#131): scripted repo review, five issues filed and fixed,
|
||||||
- **Working tree:** clean. Local `scripts/` helpers (incl. `scripts/watch_deploy.sh`) are intentionally not committed.
|
plugin module splits, two releases.
|
||||||
- **Latest verification this session:** `cargo clippy --workspace --all-targets -- -D warnings`; `cargo test --workspace`; `cargo build -p solitaire_app`; Android cross-compile + clippy for `aarch64-linux-android` (clean); full local signed arm64-v8a APK via `scripts/build_android_apk.sh`; CI `android-release` for `v0.40.0` completed/success with APK download HTTP 200.
|
- **Latest tags:** `v0.41.1` (pile-marker child-resize fix + Android relayout
|
||||||
- **Full previous gate:** card_game work pushed to origin with `cargo test` / `clippy` gates passing.
|
logging) on top of `v0.41.0` (consolidated release for everything since
|
||||||
|
v0.39.0). Both released via tag push → CI signed APK; both verified
|
||||||
|
installed on hardware (`versionCode 4101`).
|
||||||
|
- **Working tree:** clean. Local `scripts/*.go` helpers are intentionally
|
||||||
|
gitignored (`.gitignore:43`); `scripts/watch_deploy.sh` is now committed.
|
||||||
|
- **Latest verification:** workspace clippy `--all-targets -D warnings`,
|
||||||
|
full test suite, `cargo ndk` clippy for `aarch64-linux-android`, CI release
|
||||||
|
builds green, and an on-device pass on the Fold 7 (fold/unfold layout,
|
||||||
|
safe-area resume, marker fix).
|
||||||
|
- **Issue tracker:** #116/#117/#118/#119/#120 all closed 2026-07-06. #130
|
||||||
|
(transient tableau clip after fold) open at low priority — did not
|
||||||
|
reproduce in repeat testing; v0.41.1's relayout logging is the evidence
|
||||||
|
channel if it recurs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2026-07-06 session summary (v0.40.3 → v0.41.1)
|
||||||
|
|
||||||
|
- **Scripted repo review** (cratemap/todoctx/cargoclip/testfail): clippy
|
||||||
|
clean, tests green, error/SQL policies compliant. Five issues filed and
|
||||||
|
all resolved same-day.
|
||||||
|
- **#116 safe-area re-poll after resume** (PR #121): `refresh_insets` now
|
||||||
|
gates on the poll counter, settles per cycle, and rewrites insets only on
|
||||||
|
change. Verified on Fold 7 — note both Fold screens report identical
|
||||||
|
insets (top=110 bottom=0), so the re-poll path is a no-op on this device.
|
||||||
|
- **#117 Draw-1 recycle** (PR #122): unlimited recycling documented as an
|
||||||
|
intentional rules decision (ARCHITECTURE.md "Rules decisions" +
|
||||||
|
`draw_one_recycling_is_unlimited_by_design` lock-in test). A hard limit
|
||||||
|
would invalidate the difficulty seed catalog and the winnable-deal solver.
|
||||||
|
- **#118 module splits** (PRs #124/#125/#127/#128/#129): card, hud,
|
||||||
|
settings, game, and input plugins are now module directories; tests in
|
||||||
|
sibling `tests.rs`, runtime code split along system boundaries
|
||||||
|
(`pub(super)` items, mod.rs glob-imports children). Largest runtime file
|
||||||
|
is now `card_plugin/sync.rs` at 748 lines (was `card_plugin.rs` at 4,129).
|
||||||
|
- **v0.41.1 pile-marker fix** (PR #131): marker outline + "A"/"K" watermark
|
||||||
|
children are re-derived from the layout on every resize — previously
|
||||||
|
spawn-time-sized, rendering as oversized grey slabs on empty foundations
|
||||||
|
after fold/unfold. Found via photo evidence, fixed, re-verified on device.
|
||||||
|
- **Obtainium note:** reported "no suitable release" for v0.41.0 even though
|
||||||
|
the anonymous releases API, `releases/latest`, and the APK download were
|
||||||
|
all verified fine — client-side issue; sideload via adb was used instead.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,32 +168,17 @@ Three bugs fixed:
|
|||||||
|
|
||||||
## Open punch list
|
## Open punch list
|
||||||
|
|
||||||
### 1. Physical-device smoke test — THE ONLY REMAINING v0.40.0 ITEM
|
### 1. Physical-device smoke test — DONE (2026-07-06, Galaxy Fold 7)
|
||||||
|
|
||||||
This is the **single outstanding task** for the v0.40.0 Android release. Everything
|
v0.41.1 installed via adb and the full device checklist passed on hardware:
|
||||||
else is done and verified: workspace gates, `aarch64-linux-android` cross-compile +
|
fold/unfold layout on both screens (incl. the #116 resume path and the
|
||||||
clippy, release manifest sanity, a full local signed APK, the published release, and
|
pile-marker fix), safe-area inset resolution, Draw-Three waste fan tap
|
||||||
Obtainium-facing delivery (public releases API, latest non-draft release, APK
|
accuracy (#106), modal centring on both screens, drag-and-drop across all
|
||||||
downloadable anonymously). The only thing that cannot be done without hardware is
|
pile types, text rendering, kill-and-restore, and the sync token flow.
|
||||||
running the app on a real phone.
|
Reminder for future gates: AVD is not a substitute — `adb shell input tap`
|
||||||
|
doesn't deliver real touch events.
|
||||||
|
|
||||||
Install the published APK on a real Android device (not AVD) and run the checklist
|
### 2. Matomo analytics live validation (independent — NOT a release blocker)
|
||||||
in `docs/ANDROID.md §4`. This has never been gated in CI — AVD `adb shell input tap`
|
|
||||||
doesn't deliver real touch events, so physical-device smoke testing is the only gate.
|
|
||||||
|
|
||||||
The signed release APK is published (grab it from the release page, or use the local
|
|
||||||
`target/debug/apk/ferrous-solitaire.apk`). When testing, specifically exercise the
|
|
||||||
Draw-Three waste fan fixed in #106: switch to Draw-Three, draw several cards, and
|
|
||||||
confirm dragging the visible top waste card plays *that* card, not the one beneath it.
|
|
||||||
|
|
||||||
Latest AVD smoke (2026-06-08 local / 2026-06-09 UTC): built
|
|
||||||
`target/debug/apk/ferrous-solitaire.apk` for `x86_64-linux-android`, installed
|
|
||||||
it on AVD `Pixel_7`, launched `android.app.NativeActivity`, confirmed Bevy
|
|
||||||
rendered the board, safe-area insets resolved as `top=136 bottom=63 left=0
|
|
||||||
right=0` after 2 frames, onboarding could be dismissed via AVD input, and
|
|
||||||
filtered logcat showed no Ferrous panic/fatal/ANR.
|
|
||||||
|
|
||||||
### 2. Matomo analytics live validation (independent — NOT a v0.40.0 release blocker)
|
|
||||||
|
|
||||||
Separate, ongoing task unrelated to the Android release. `Settings` has
|
Separate, ongoing task unrelated to the Android release. `Settings` has
|
||||||
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
`analytics_enabled`, `matomo_url`, and `matomo_site_id`; the engine consumes them via
|
||||||
@@ -164,6 +190,24 @@ validation checklist and the current web/WASM decision notes.
|
|||||||
|
|
||||||
## Architectural notes for next session
|
## Architectural notes for next session
|
||||||
|
|
||||||
|
- **Plugin submodule pattern (2026-07-06 splits):** big plugins are module
|
||||||
|
directories: `mod.rs` holds types/markers/plugin-build and glob-imports the
|
||||||
|
children (`use input::*;`); children hold `pub(super)` systems and start
|
||||||
|
with `use super::*;` plus their own external imports. Tests (`tests.rs`)
|
||||||
|
may need explicit imports for names no longer used by `mod.rs` itself.
|
||||||
|
|
||||||
|
- **Marker child-resize rule:** anything spawned as a *child* of a
|
||||||
|
layout-sized entity (outline frames, watermark text) must be re-derived in
|
||||||
|
`on_window_resized` too — resizing only the parent sprite leaves children
|
||||||
|
at spawn-time size (the v0.41.1 foldable bug).
|
||||||
|
|
||||||
|
- **Fold 7 quirks:** both screens report identical safe-area insets
|
||||||
|
(top=110, bottom=0), so inset-driven relayout never fires on fold; layout
|
||||||
|
correctness across folds rides entirely on `WindowResized`. winit 0.30
|
||||||
|
logs `TODO: find a way to notify application of content rect change` on
|
||||||
|
resume — see #130 if a stale-width layout ever reproduces; v0.41.1 logs
|
||||||
|
every Android relayout (`layout: resize to WxH`) for exactly this.
|
||||||
|
|
||||||
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
|
- **Reduce-motion pattern:** always gate in the `start_*` / `detect_*` system
|
||||||
(the trigger), not the `tick_*` system. If the component is never inserted, the
|
(the trigger), not the `tick_*` system. If the component is never inserted, the
|
||||||
tick path never runs. See `hud_plugin.rs::detect_score_change` and
|
tick path never runs. See `hud_plugin.rs::detect_score_change` and
|
||||||
|
|||||||
+8
-5
@@ -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
|
||||||
|
|
||||||
@@ -67,7 +68,9 @@ if ! command -v wasm-bindgen &> /dev/null; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Building solitaire_web (Bevy WASM app)..."
|
echo "Building solitaire_web (Bevy WASM app)..."
|
||||||
cargo build --release --target wasm32-unknown-unknown -p solitaire_web
|
# wasm-release is the size-focused profile (fat LTO, CGU=1, opt-level "s") —
|
||||||
|
# see [profile.wasm-release] in Cargo.toml. Download size is the constraint.
|
||||||
|
cargo build --profile wasm-release --target wasm32-unknown-unknown -p solitaire_web
|
||||||
|
|
||||||
echo "Running wasm-bindgen for solitaire_web..."
|
echo "Running wasm-bindgen for solitaire_web..."
|
||||||
wasm-bindgen \
|
wasm-bindgen \
|
||||||
@@ -75,7 +78,7 @@ wasm-bindgen \
|
|||||||
--out-name canvas \
|
--out-name canvas \
|
||||||
--target web \
|
--target web \
|
||||||
--no-typescript \
|
--no-typescript \
|
||||||
"$REPO_ROOT/target/wasm32-unknown-unknown/release/solitaire_web.wasm"
|
"${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.
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ spec:
|
|||||||
key: jwt-secret
|
key: jwt-secret
|
||||||
- name: SERVER_PORT
|
- name: SERVER_PORT
|
||||||
value: "8080"
|
value: "8080"
|
||||||
|
# Theme-store catalog directory on the persistent volume.
|
||||||
|
# Scanned once at startup — after dropping new theme zips
|
||||||
|
# into /data/theme_store, restart the deployment.
|
||||||
|
- name: THEME_STORE_DIR
|
||||||
|
value: /data/theme_store
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: db-data
|
- name: db-data
|
||||||
mountPath: /data
|
mountPath: /data
|
||||||
|
|||||||
@@ -6,8 +6,13 @@ services:
|
|||||||
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
# Override DATABASE_URL so the DB always lands in the persistent volume,
|
||||||
# regardless of what .env contains.
|
# regardless of what .env contains.
|
||||||
DATABASE_URL: sqlite:///data/solitaire.db
|
DATABASE_URL: sqlite:///data/solitaire.db
|
||||||
|
# Theme-store catalog directory (scanned once at startup; the
|
||||||
|
# host ./theme_store folder is where the operator drops theme
|
||||||
|
# zips + preview PNGs).
|
||||||
|
THEME_STORE_DIR: /theme_store
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/data
|
- ./data:/data
|
||||||
|
- ./theme_store:/theme_store:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
- "${SERVER_PORT:-8080}"
|
- "${SERVER_PORT:-8080}"
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
# Menu UX Redesign — July 2026
|
||||||
|
|
||||||
|
Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on
|
||||||
|
`feat/home-hierarchy` (2026-07-13); F / H and the I–M backlog remain open.
|
||||||
|
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 vs. keep global row~~ —
|
||||||
|
**DECIDED 2026-07-09: disclosure on the Classic card / New Game hero.**
|
||||||
|
3. ~~Time Attack + Seed: top-level cards vs. "More" card~~ —
|
||||||
|
**DECIDED 2026-07-09: top-level cards, symmetric 2×3 grid.**
|
||||||
|
4. ~~Stitch mockups for Phase B, or iterate directly in-engine~~ —
|
||||||
|
**DECIDED 2026-07-09: directly in-engine.**
|
||||||
|
5. ~~Phase F bottom bar: touch-only vs. also desktop~~ —
|
||||||
|
**DECIDED 2026-07-13: touch-only.** Desktop keeps the current top
|
||||||
|
band; the bar is additive per §3.3 so a later desktop rollout stays
|
||||||
|
a settings flag away.
|
||||||
Executable
+65
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Live-watch the Gitea Actions deploy pipeline for Ferrous Solitaire.
|
||||||
|
#
|
||||||
|
# Polls recent workflow runs and prints a compact status block each cycle.
|
||||||
|
# Stops when the newest docker-build (the deploy) has completed — the wasm
|
||||||
|
# is built inside that image build, so no other workflow gates the deploy.
|
||||||
|
#
|
||||||
|
# Usage: ./scripts/watch_deploy.sh [interval_seconds]
|
||||||
|
# token is read from ~/.config/tea/config.yml (never printed).
|
||||||
|
set -uo pipefail
|
||||||
|
|
||||||
|
REPO="funman300/Ferrous-Solitaire"
|
||||||
|
BASE="https://git.aleshym.co/api/v1/repos/${REPO}"
|
||||||
|
INTERVAL="${1:-20}"
|
||||||
|
CFG="${HOME}/.config/tea/config.yml"
|
||||||
|
|
||||||
|
TOKEN="$(grep -E '^[[:space:]]*token:' "$CFG" | head -1 | sed -E 's/.*token:[[:space:]]*//' | tr -d '"'\'' ')"
|
||||||
|
[ -z "$TOKEN" ] && { echo "error: no token in $CFG" >&2; exit 1; }
|
||||||
|
|
||||||
|
echo "── watching ${REPO} deploy (poll ${INTERVAL}s, Ctrl-C to stop) ──"
|
||||||
|
|
||||||
|
while :; do
|
||||||
|
json="$(curl -s --max-time 20 -H "Authorization: token ${TOKEN}" "${BASE}/actions/runs?limit=6")"
|
||||||
|
# Print rows + emit the deploy state on the last line (parsed below).
|
||||||
|
# JSON is passed via env var because `python3 -` reads its program from the
|
||||||
|
# heredoc on stdin, so stdin can't also carry the data.
|
||||||
|
out="$(JSON_DATA="$json" python3 - <<'PY'
|
||||||
|
import os, sys, json, datetime
|
||||||
|
now = datetime.datetime.now().strftime("%H:%M:%S")
|
||||||
|
raw = os.environ.get("JSON_DATA", "").strip()
|
||||||
|
try:
|
||||||
|
d = json.loads(raw)
|
||||||
|
except Exception:
|
||||||
|
print("[%s] (api unavailable, retrying)" % now)
|
||||||
|
print("STATE=DEPLOYING")
|
||||||
|
sys.exit(0)
|
||||||
|
runs = d.get("workflow_runs", [])[:6]
|
||||||
|
icons = {("completed","success"):"OK ", ("completed","failure"):"FAIL",
|
||||||
|
("completed","cancelled"):"CXL "}
|
||||||
|
def ic(s, c):
|
||||||
|
if s == "queued": return "queue"
|
||||||
|
if s == "in_progress": return "run.."
|
||||||
|
return icons.get((s, c), s or "?")
|
||||||
|
print("[%s]" % datetime.datetime.now().strftime("%H:%M:%S"))
|
||||||
|
for r in runs:
|
||||||
|
wf = str(r.get("path","")).split("@")[0].split("/")[-1].replace(".yml","")
|
||||||
|
print(" %-5s %-5s %-7s %-18s %s/%s" % (
|
||||||
|
ic(r.get("status"), r.get("conclusion")),
|
||||||
|
r.get("id"), str(r.get("head_sha"))[:7], wf[:18],
|
||||||
|
r.get("status"), r.get("conclusion")))
|
||||||
|
db = [r for r in runs if "docker-build" in str(r.get("path"))]
|
||||||
|
top = db[0] if db else None
|
||||||
|
live = bool(top and top.get("status")=="completed" and top.get("conclusion")=="success")
|
||||||
|
print("STATE=%s" % ("LIVE" if live else "DEPLOYING"))
|
||||||
|
PY
|
||||||
|
)"
|
||||||
|
echo "$out" | grep -v '^STATE='
|
||||||
|
if echo "$out" | grep -q '^STATE=LIVE'; then
|
||||||
|
echo ""
|
||||||
|
echo "DEPLOY LIVE — newest docker-build succeeded."
|
||||||
|
echo " Test: https://klondike.aleshym.co/play?v=${RANDOM}"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep "$INTERVAL"
|
||||||
|
done
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -9,13 +9,19 @@ default = []
|
|||||||
test-support = []
|
test-support = []
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
proptest = "1"
|
proptest = "1"
|
||||||
|
serde_json = { workspace = true }
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
serde = { workspace = true }
|
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
|
||||||
|
|||||||
@@ -22,11 +22,17 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
|||||||
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
/// indices for enum variants. No longer loadable — v3 files are discarded.
|
||||||
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
|
||||||
/// variants (e.g. `"Foundation1"` instead of `0`).
|
/// variants (e.g. `"Foundation1"` instead of `0`).
|
||||||
/// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
|
/// - v5: `score`, `undo_count`, and `recycle_count` are no longer
|
||||||
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
/// persisted. They are derived from the upstream `card_game`/`klondike` session
|
||||||
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
|
||||||
/// still carry those keys load fine — the extra fields are ignored.
|
/// still carry those keys load fine — the extra fields are ignored.
|
||||||
pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
|
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream
|
||||||
|
/// `card_game` session serialisation carrying the dealt board explicitly.
|
||||||
|
/// Loading no longer re-deals from `seed`, so in-progress saves survive
|
||||||
|
/// RNG/upstream upgrades that change the seed→deal mapping (the failure
|
||||||
|
/// class that PR #170 fixed for replays). v4/v5 files still load through
|
||||||
|
/// the legacy seed path and are rewritten as v6 on next save.
|
||||||
|
pub const GAME_STATE_SCHEMA_VERSION: u32 = 6;
|
||||||
|
|
||||||
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
/// Default move budget for a solvability check. Matches the winnable-deal retry
|
||||||
/// loop in the engine.
|
/// loop in the engine.
|
||||||
@@ -95,8 +101,8 @@ pub enum GameMode {
|
|||||||
Difficulty(DifficultyLevel),
|
Difficulty(DifficultyLevel),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output struct for schema v4 serialisation. `saved_moves` uses upstream
|
/// Output struct for schema v6 serialisation. `recording` is the upstream
|
||||||
/// `KlondikeInstruction` serde, which produces named enum variants.
|
/// session serialisation (config + dealt board + instruction list).
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
struct PersistedGameState {
|
struct PersistedGameState {
|
||||||
pub draw_mode: DrawStockConfig,
|
pub draw_mode: DrawStockConfig,
|
||||||
@@ -105,15 +111,16 @@ struct PersistedGameState {
|
|||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub recording: SessionRecording,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Input struct that accepts schema v4 and v5 `saved_moves` formats.
|
/// Input struct that accepts schema v4, v5, and v6 save formats.
|
||||||
///
|
///
|
||||||
/// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
|
/// v6 files carry `recording` (the upstream session serialisation, deal
|
||||||
/// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
|
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from
|
||||||
/// intentionally absent: all three are rebuilt by replaying the instruction
|
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally
|
||||||
/// history through the upstream session stats. Older v4 save files still carry
|
/// absent: all three are rebuilt by replaying the instruction history
|
||||||
|
/// through the upstream session stats. Older v4 save files still carry
|
||||||
/// those keys; serde ignores them.
|
/// those keys; serde ignores them.
|
||||||
#[derive(Debug, Clone, Deserialize)]
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
struct PersistedGameStateIn {
|
struct PersistedGameStateIn {
|
||||||
@@ -126,7 +133,12 @@ struct PersistedGameStateIn {
|
|||||||
pub take_from_foundation: bool,
|
pub take_from_foundation: bool,
|
||||||
#[serde(default = "schema_v1")]
|
#[serde(default = "schema_v1")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
|
/// v4/v5 only. Replayed against a seed-dealt board.
|
||||||
|
#[serde(default)]
|
||||||
pub saved_moves: Vec<KlondikeInstruction>,
|
pub saved_moves: Vec<KlondikeInstruction>,
|
||||||
|
/// v6 only. Carries the dealt board explicitly.
|
||||||
|
#[serde(default)]
|
||||||
|
pub recording: Option<SessionRecording>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -222,7 +234,7 @@ impl Serialize for GameState {
|
|||||||
seed: self.seed,
|
seed: self.seed,
|
||||||
take_from_foundation: self.take_from_foundation,
|
take_from_foundation: self.take_from_foundation,
|
||||||
schema_version: GAME_STATE_SCHEMA_VERSION,
|
schema_version: GAME_STATE_SCHEMA_VERSION,
|
||||||
saved_moves: self.saved_moves(),
|
recording: self.recording(),
|
||||||
}
|
}
|
||||||
.serialize(serializer)
|
.serialize(serializer)
|
||||||
}
|
}
|
||||||
@@ -232,34 +244,51 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
let persisted = PersistedGameStateIn::deserialize(deserializer)?;
|
||||||
|
|
||||||
// Accept v4 (upstream named-variant serde) and v5 (current, derived
|
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt
|
||||||
// stats). v3 (legacy u8-index format) and all others are rejected.
|
// saves — loadable while the seed→deal mapping they were written
|
||||||
match persisted.schema_version {
|
// under still holds; rewritten as v6 on the next save). v3 (legacy
|
||||||
4 | 5 => {}
|
// u8-index format) and all others are rejected.
|
||||||
|
let (mut game, instructions) = match persisted.schema_version {
|
||||||
|
6 => {
|
||||||
|
let Some(recording) = persisted.recording else {
|
||||||
|
return Err(serde::de::Error::custom(
|
||||||
|
"v6 save file is missing the session recording",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
// The dealt board and session config come from the recording
|
||||||
|
// itself; `seed` is presentation metadata only.
|
||||||
|
Self::from_recording(&recording, persisted.seed, persisted.mode)
|
||||||
|
}
|
||||||
|
4 | 5 => (
|
||||||
|
Self {
|
||||||
|
mode: persisted.mode,
|
||||||
|
elapsed_seconds: 0,
|
||||||
|
seed: persisted.seed,
|
||||||
|
take_from_foundation: true,
|
||||||
|
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
test_pile_state: None,
|
||||||
|
},
|
||||||
|
persisted.saved_moves,
|
||||||
|
),
|
||||||
v => {
|
v => {
|
||||||
return Err(serde::de::Error::custom(format!(
|
return Err(serde::de::Error::custom(format!(
|
||||||
"unsupported GameState schema version {v}"
|
"unsupported GameState schema version {v}"
|
||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
let mut game = Self {
|
|
||||||
mode: persisted.mode,
|
|
||||||
elapsed_seconds: persisted.elapsed_seconds,
|
|
||||||
seed: persisted.seed,
|
|
||||||
take_from_foundation: persisted.take_from_foundation,
|
|
||||||
session: Self::new_session(persisted.seed, persisted.draw_mode),
|
|
||||||
#[cfg(feature = "test-support")]
|
|
||||||
test_pile_state: None,
|
|
||||||
};
|
};
|
||||||
|
game.mode = persisted.mode;
|
||||||
|
game.elapsed_seconds = persisted.elapsed_seconds;
|
||||||
|
game.take_from_foundation = persisted.take_from_foundation;
|
||||||
|
|
||||||
// Replay the saved instruction history. The upstream session tracks
|
// Replay the saved instruction history with validation — a tampered
|
||||||
// score components and recycle_count as it processes each move, so the
|
// or corrupt file must surface an error, not a silently wrong board.
|
||||||
// derived stats are correct once replay completes. `undo_count()` resets
|
// The upstream session tracks score components and recycle_count as
|
||||||
// to 0 across save/load because undone moves are not part of the saved
|
// it processes each move, so the derived stats are correct once
|
||||||
// forward history.
|
// replay completes. `undo_count()` resets to 0 across save/load
|
||||||
|
// because undone moves are not part of the saved forward history.
|
||||||
let replay_config = Self::replay_config(persisted.draw_mode);
|
let replay_config = Self::replay_config(persisted.draw_mode);
|
||||||
for instruction in persisted.saved_moves {
|
for instruction in instructions {
|
||||||
if !game
|
if !game
|
||||||
.session
|
.session
|
||||||
.state()
|
.state()
|
||||||
@@ -277,6 +306,90 @@ impl<'de> Deserialize<'de> for GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Self-contained recording of one deal plus every instruction applied to it.
|
||||||
|
///
|
||||||
|
/// Serialises via the upstream `card_game` [`Session`] serde, whose wire
|
||||||
|
/// format is `{config, initial_state, instructions}` — the dealt board is
|
||||||
|
/// stored **explicitly**, so playback never depends on the seed→deal mapping
|
||||||
|
/// staying stable across RNG or upstream-crate upgrades. This is the payload
|
||||||
|
/// replays must persist; a bare seed is only sufficient for the exact build
|
||||||
|
/// that recorded it.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionRecording(Session<Klondike>);
|
||||||
|
|
||||||
|
impl SessionRecording {
|
||||||
|
/// Builds a recording by dealing a fresh board from `seed` and
|
||||||
|
/// force-applying `instructions` **without validation**.
|
||||||
|
///
|
||||||
|
/// Fixture/test aid only — production recordings come from
|
||||||
|
/// [`GameState::recording`], whose history is valid by construction.
|
||||||
|
/// Invalid instructions are absorbed by the upstream session's
|
||||||
|
/// `Option`-based pile pops rather than rejected, so a recording built
|
||||||
|
/// here may not replay cleanly through
|
||||||
|
/// [`GameState::apply_instruction`]'s validation.
|
||||||
|
pub fn from_instructions_unchecked(
|
||||||
|
seed: u64,
|
||||||
|
draw_mode: DrawStockConfig,
|
||||||
|
instructions: impl IntoIterator<Item = KlondikeInstruction>,
|
||||||
|
) -> Self {
|
||||||
|
let mut session = GameState::new_session(seed, draw_mode);
|
||||||
|
for instruction in instructions {
|
||||||
|
session.process_instruction(instruction);
|
||||||
|
}
|
||||||
|
Self(session)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The dealt board the recording starts from (before any instruction).
|
||||||
|
fn initial_state(&self) -> &Klondike {
|
||||||
|
self.0
|
||||||
|
.history()
|
||||||
|
.first()
|
||||||
|
.map(|snapshot| snapshot.state())
|
||||||
|
.unwrap_or_else(|| self.0.state().state())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ordered instruction list, replayable via
|
||||||
|
/// [`GameState::apply_instruction`] against the game returned by
|
||||||
|
/// [`GameState::from_recording`].
|
||||||
|
pub fn instructions(&self) -> Vec<KlondikeInstruction> {
|
||||||
|
self.0
|
||||||
|
.history()
|
||||||
|
.iter()
|
||||||
|
.map(|snapshot| *snapshot.instruction())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of recorded instructions.
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.0.history().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when no instructions have been recorded.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.0.history().is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartialEq for SessionRecording {
|
||||||
|
fn eq(&self, other: &Self) -> bool {
|
||||||
|
self.initial_state() == other.initial_state() && self.instructions() == other.instructions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Eq for SessionRecording {}
|
||||||
|
|
||||||
|
impl Serialize for SessionRecording {
|
||||||
|
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
self.0.serialize(serializer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for SessionRecording {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||||
|
Session::deserialize(deserializer).map(Self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl GameState {
|
impl GameState {
|
||||||
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
/// Creates a new Classic-mode game dealt from the given seed and draw mode.
|
||||||
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
pub fn new(seed: u64, draw_mode: DrawStockConfig) -> Self {
|
||||||
@@ -296,6 +409,44 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot of the live session for replay persistence: the dealt board
|
||||||
|
/// plus the forward instruction history (undone moves are absent — the
|
||||||
|
/// session pops them). Serialise the returned [`SessionRecording`] with
|
||||||
|
/// the upstream `card_game` serializers; rebuild playback with
|
||||||
|
/// [`Self::from_recording`].
|
||||||
|
pub fn recording(&self) -> SessionRecording {
|
||||||
|
SessionRecording(self.session.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebuilds the initial-deal game plus the ordered instruction list from
|
||||||
|
/// a [`SessionRecording`].
|
||||||
|
///
|
||||||
|
/// The board and the session config (including draw mode) come from the
|
||||||
|
/// recording itself, so playback is independent of the current build's
|
||||||
|
/// seed→deal mapping. `seed` and `mode` are presentation metadata carried
|
||||||
|
/// alongside the recording by the replay file. Step through the returned
|
||||||
|
/// instructions with [`Self::apply_instruction`], which re-validates each
|
||||||
|
/// one and fails gracefully on corrupt input.
|
||||||
|
pub fn from_recording(
|
||||||
|
recording: &SessionRecording,
|
||||||
|
seed: u64,
|
||||||
|
mode: GameMode,
|
||||||
|
) -> (Self, Vec<KlondikeInstruction>) {
|
||||||
|
let game = Self {
|
||||||
|
mode,
|
||||||
|
elapsed_seconds: 0,
|
||||||
|
seed,
|
||||||
|
take_from_foundation: true,
|
||||||
|
session: Session::new(
|
||||||
|
recording.initial_state().clone(),
|
||||||
|
recording.0.config().clone(),
|
||||||
|
),
|
||||||
|
#[cfg(feature = "test-support")]
|
||||||
|
test_pile_state: None,
|
||||||
|
};
|
||||||
|
(game, recording.instructions())
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether the player draws one or three cards from the stock per turn.
|
/// Whether the player draws one or three cards from the stock per turn.
|
||||||
/// Derived from the underlying session config (set once at deal time).
|
/// Derived from the underlying session config (set once at deal time).
|
||||||
pub fn draw_mode(&self) -> DrawStockConfig {
|
pub fn draw_mode(&self) -> DrawStockConfig {
|
||||||
@@ -339,6 +490,32 @@ impl GameState {
|
|||||||
self.session.stats().stats().recycle_count()
|
self.session.stats().stats().recycle_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Number of cards moved onto foundations this game, read from the
|
||||||
|
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
|
||||||
|
/// not rolled back on undo.
|
||||||
|
pub fn move_to_foundation_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().move_to_foundation_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of stacks moved onto tableaus (from stock or another tableau)
|
||||||
|
/// this game, read from the upstream session stats. Cumulative — not
|
||||||
|
/// rolled back on undo.
|
||||||
|
pub fn move_to_tableau_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().move_to_tableau_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of cards taken back off a foundation this game, read from the
|
||||||
|
/// upstream session stats. Cumulative — not rolled back on undo.
|
||||||
|
pub fn move_from_foundation_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().move_from_foundation_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of face-down cards revealed (flipped up) this game, read from
|
||||||
|
/// the upstream session stats. Cumulative — not rolled back on undo.
|
||||||
|
pub fn flip_up_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().flip_up_bonus_count()
|
||||||
|
}
|
||||||
|
|
||||||
/// Total moves made this game (draws, recycles, and card moves), derived
|
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||||
/// from the session's instruction history length.
|
/// from the session's instruction history length.
|
||||||
pub fn move_count(&self) -> u32 {
|
pub fn move_count(&self) -> u32 {
|
||||||
@@ -402,16 +579,6 @@ impl GameState {
|
|||||||
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collects the session instruction history as upstream types for schema v4
|
|
||||||
/// serialisation.
|
|
||||||
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
|
|
||||||
self.session
|
|
||||||
.history()
|
|
||||||
.iter()
|
|
||||||
.map(|snapshot| *snapshot.instruction())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Returns the deterministic instruction history for the current deal as
|
/// Returns the deterministic instruction history for the current deal as
|
||||||
/// upstream [`KlondikeInstruction`] values.
|
/// upstream [`KlondikeInstruction`] values.
|
||||||
///
|
///
|
||||||
@@ -439,10 +606,7 @@ impl GameState {
|
|||||||
self.session.history().len()
|
self.session.history().len()
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cards_with_face(
|
fn cards_with_face(cards: impl IntoIterator<Item = Card>, face_up: bool) -> Vec<(Card, bool)> {
|
||||||
cards: impl IntoIterator<Item = Card>,
|
|
||||||
face_up: bool,
|
|
||||||
) -> Vec<(Card, bool)> {
|
|
||||||
cards.into_iter().map(|card| (card, face_up)).collect()
|
cards.into_iter().map(|card| (card, face_up)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,8 +671,10 @@ impl GameState {
|
|||||||
Self::cards_with_face(cards.iter().cloned(), true)
|
Self::cards_with_face(cards.iter().cloned(), true)
|
||||||
}
|
}
|
||||||
KlondikePile::Tableau(tableau) => {
|
KlondikePile::Tableau(tableau) => {
|
||||||
let mut cards =
|
let mut cards = Self::cards_with_face(
|
||||||
Self::cards_with_face(state.tableau_face_down_cards(tableau).iter().cloned(), false);
|
state.tableau_face_down_cards(tableau).iter().cloned(),
|
||||||
|
false,
|
||||||
|
);
|
||||||
cards.extend(Self::cards_with_face(
|
cards.extend(Self::cards_with_face(
|
||||||
state.tableau_face_up_cards(tableau).iter().cloned(),
|
state.tableau_face_up_cards(tableau).iter().cloned(),
|
||||||
true,
|
true,
|
||||||
@@ -823,9 +989,7 @@ impl GameState {
|
|||||||
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
) -> Option<(KlondikePile, KlondikePile, usize)> {
|
||||||
let state = self.session.state().state().state();
|
let state = self.session.state().state().state();
|
||||||
match instruction {
|
match instruction {
|
||||||
KlondikeInstruction::RotateStock => {
|
KlondikeInstruction::RotateStock => Some((KlondikePile::Stock, KlondikePile::Stock, 1)),
|
||||||
Some((KlondikePile::Stock, KlondikePile::Stock, 1))
|
|
||||||
}
|
|
||||||
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
KlondikeInstruction::DstFoundation(dst_foundation) => {
|
||||||
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
if matches!(dst_foundation.src, KlondikePile::Foundation(_)) {
|
||||||
return None;
|
return None;
|
||||||
@@ -863,6 +1027,13 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
/// Draw cards from stock to waste. When stock is empty, recycles waste back to stock.
|
||||||
|
///
|
||||||
|
/// Recycling is deliberately unlimited in every draw mode: extra passes
|
||||||
|
/// are discouraged through the upstream score penalty, never rejected
|
||||||
|
/// with a [`MoveError`]. The difficulty seed catalog and the
|
||||||
|
/// winnable-deal solver are verified under this rule — see the
|
||||||
|
/// "Rules decisions" note in `ARCHITECTURE.md` (`solitaire_core`
|
||||||
|
/// section) before considering a hard pass limit.
|
||||||
pub fn draw(&mut self) -> Result<(), MoveError> {
|
pub fn draw(&mut self) -> Result<(), MoveError> {
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
@@ -921,10 +1092,7 @@ impl GameState {
|
|||||||
///
|
///
|
||||||
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
/// Returns [`MoveError::RuleViolation`] if the instruction is illegal in the
|
||||||
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
/// current position, or [`MoveError::GameAlreadyWon`] once the game is over.
|
||||||
pub fn apply_instruction(
|
pub fn apply_instruction(&mut self, instruction: KlondikeInstruction) -> Result<(), MoveError> {
|
||||||
&mut self,
|
|
||||||
instruction: KlondikeInstruction,
|
|
||||||
) -> Result<(), MoveError> {
|
|
||||||
if self.is_won() {
|
if self.is_won() {
|
||||||
return Err(MoveError::GameAlreadyWon);
|
return Err(MoveError::GameAlreadyWon);
|
||||||
}
|
}
|
||||||
@@ -1108,6 +1276,66 @@ impl GameState {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Full winning line from the current position:
|
||||||
|
///
|
||||||
|
/// * `Ok(Some(line))` — winnable; applying every instruction in order via
|
||||||
|
/// [`GameState::apply_instruction`] reaches a won game.
|
||||||
|
/// * `Ok(None)` — provably unwinnable, or already won (no moves to show).
|
||||||
|
/// * `Err(SolveError)` — inconclusive; budget exhausted before a verdict.
|
||||||
|
///
|
||||||
|
/// Delegates to upstream [`card_game::Session::solve`] on a solve-budgeted
|
||||||
|
/// copy of the board like [`GameState::solve_first_move`], but returns the
|
||||||
|
/// whole path instead of the first move, compacted with
|
||||||
|
/// [`card_game::Solution::clean_solution`] (drops move ranges that loop
|
||||||
|
/// back to an already-seen state — the raw DFS trace is full of them).
|
||||||
|
///
|
||||||
|
/// Foundation→foundation shuffles ([`KlondikeInstruction::is_useless`])
|
||||||
|
/// are additionally stripped when the remaining sequence still replays to
|
||||||
|
/// a win; if stripping one would break a later move's preconditions the
|
||||||
|
/// unstripped cleaned line is returned instead, so the replay contract
|
||||||
|
/// above always holds. `clean_solution` is quadratic-ish in line length —
|
||||||
|
/// callers should run this off the UI thread with modest budgets.
|
||||||
|
pub fn winning_line(
|
||||||
|
&self,
|
||||||
|
moves_budget: u64,
|
||||||
|
states_budget: u64,
|
||||||
|
) -> Result<Option<Vec<KlondikeInstruction>>, SolveError> {
|
||||||
|
if self.is_won() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let inner = KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation);
|
||||||
|
let config = SessionConfig {
|
||||||
|
inner: inner.clone(),
|
||||||
|
undo_penalty: 0,
|
||||||
|
solve_moves_budget: moves_budget,
|
||||||
|
solve_states_budget: states_budget,
|
||||||
|
};
|
||||||
|
let start = self.session.state().state().clone();
|
||||||
|
let session = Session::new(start.clone(), config);
|
||||||
|
|
||||||
|
let Some(solution) = session.solve()? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
let cleaned: Vec<KlondikeInstruction> = solution
|
||||||
|
.clean_solution()
|
||||||
|
.iter()
|
||||||
|
.map(|snapshot| *snapshot.instruction())
|
||||||
|
.collect();
|
||||||
|
let filtered: Vec<KlondikeInstruction> = cleaned
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.filter(|instruction| !instruction.is_useless())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if line_replays_to_win(start, &inner, &filtered) {
|
||||||
|
Ok(Some(filtered))
|
||||||
|
} else {
|
||||||
|
Ok(Some(cleaned))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
|
/// Solvability of a fresh Classic-mode deal from `seed` + `draw_mode`.
|
||||||
///
|
///
|
||||||
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
/// Fresh-deal solving models standard Klondike rules, so the non-standard
|
||||||
@@ -1125,6 +1353,25 @@ impl GameState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `true` when applying `line` in order from `start` is legal at every step
|
||||||
|
/// and ends in a won game. Pure replay check backing
|
||||||
|
/// [`GameState::winning_line`]'s "the returned sequence always replays to a
|
||||||
|
/// win" contract.
|
||||||
|
fn line_replays_to_win(
|
||||||
|
mut state: Klondike,
|
||||||
|
config: &KlondikeConfig,
|
||||||
|
line: &[KlondikeInstruction],
|
||||||
|
) -> bool {
|
||||||
|
let mut stats = <Klondike as card_game::Game>::Stats::default();
|
||||||
|
for &instruction in line {
|
||||||
|
if !state.is_instruction_valid(config, instruction) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
state.process_instruction(&mut stats, config, instruction);
|
||||||
|
}
|
||||||
|
state.is_win()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -1222,6 +1469,30 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_one_recycling_is_unlimited_by_design() {
|
||||||
|
// Rules decision (Gitea #117): stock recycling is unlimited in every
|
||||||
|
// draw mode. Extra passes are discouraged via the upstream score
|
||||||
|
// penalty, never blocked with a MoveError. The difficulty seed
|
||||||
|
// catalog and the winnable-deal solver are verified under this rule,
|
||||||
|
// so a hard pass limit must not be introduced casually — see the
|
||||||
|
// "Rules decisions" note in ARCHITECTURE.md (`solitaire_core`).
|
||||||
|
let mut game = game_at_first_recycle().expect("could not reach recycle");
|
||||||
|
// ~24 draws per Draw-1 pass; 2_000 draws is ample budget for 10 passes.
|
||||||
|
for _ in 0..2_000 {
|
||||||
|
if game.recycle_count() >= 10 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
game.draw()
|
||||||
|
.expect("unlimited recycling: draw must never fail on pass count");
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
game.recycle_count() >= 10,
|
||||||
|
"expected at least 10 recycles within the draw budget, got {}",
|
||||||
|
game.recycle_count(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn undo_applies_minus_15_penalty_via_upstream_score() {
|
fn undo_applies_minus_15_penalty_via_upstream_score() {
|
||||||
// A foundation move scores +10 upstream; undoing it nets the move score
|
// A foundation move scores +10 upstream; undoing it nets the move score
|
||||||
@@ -1265,12 +1536,13 @@ mod tests {
|
|||||||
|
|
||||||
game.take_from_foundation = false;
|
game.take_from_foundation = false;
|
||||||
assert!(!game.can_move_cards(&from, &to, 1));
|
assert!(!game.can_move_cards(&from, &to, 1));
|
||||||
assert!(
|
assert!(legal_pile_moves(&game).iter().all(|(f, t, _)| !matches!(
|
||||||
legal_pile_moves(&game)
|
f,
|
||||||
.iter()
|
KlondikePile::Foundation(_)
|
||||||
.all(|(f, t, _)| !matches!(f, KlondikePile::Foundation(_))
|
) || !matches!(
|
||||||
|| !matches!(t, KlondikePile::Tableau(_)))
|
t,
|
||||||
);
|
KlondikePile::Tableau(_)
|
||||||
|
)));
|
||||||
assert!(game.move_cards(from, to, 1).is_err());
|
assert!(game.move_cards(from, to, 1).is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1325,14 +1597,244 @@ mod tests {
|
|||||||
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Full winning line (winning_line) ──────────────────────────────────
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_line_replays_to_a_won_game() {
|
||||||
|
// Seed 0xD1FF_0000_0000_0012 / DrawOne is proven Winnable at 5k
|
||||||
|
// budgets by `budget_is_passed_through_not_clamped`. Standard rules
|
||||||
|
// (take-from-foundation off) keep the search space identical to
|
||||||
|
// that baseline. The returned line must apply cleanly through the
|
||||||
|
// normal instruction pipeline and end in a win — the whole point
|
||||||
|
// of the API contract.
|
||||||
|
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
|
||||||
|
game.take_from_foundation = false;
|
||||||
|
let line = game
|
||||||
|
.winning_line(5_000, 5_000)
|
||||||
|
.expect("this seed must not exhaust a 5k budget")
|
||||||
|
.expect("this seed must be winnable");
|
||||||
|
assert!(!line.is_empty(), "a winnable unfinished game needs moves");
|
||||||
|
for (i, instruction) in line.iter().enumerate() {
|
||||||
|
game.apply_instruction(*instruction)
|
||||||
|
.unwrap_or_else(|e| panic!("move {i} of the line must be legal: {e}"));
|
||||||
|
}
|
||||||
|
assert!(game.is_won(), "line must end in a won game");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_line_contains_no_useless_moves() {
|
||||||
|
// The foundation→foundation strip must survive the replay check on
|
||||||
|
// this seed; a line shown to the player should never shuffle
|
||||||
|
// between foundations. Same proven-winnable seed and standard
|
||||||
|
// rules as `winning_line_replays_to_a_won_game`.
|
||||||
|
let mut game = GameState::new(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne);
|
||||||
|
game.take_from_foundation = false;
|
||||||
|
let line = game
|
||||||
|
.winning_line(5_000, 5_000)
|
||||||
|
.expect("budget")
|
||||||
|
.expect("winnable");
|
||||||
|
assert!(
|
||||||
|
!line.iter().any(KlondikeInstruction::is_useless),
|
||||||
|
"filtered line must not contain foundation→foundation moves"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_line_is_inconclusive_when_budget_exhausted() {
|
||||||
|
let game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||||
|
let outcome = game.winning_line(5_000, 0);
|
||||||
|
assert!(matches!(outcome, Err(SolveError::StatesBudgetExceeded)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn winning_line_matches_first_move_verdict() {
|
||||||
|
// The two solver entry points must agree on winnability for the
|
||||||
|
// same position and budgets (both are deterministic DFS).
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let first = game.solve_first_move(5_000, 5_000);
|
||||||
|
let line = game.winning_line(5_000, 5_000);
|
||||||
|
assert_eq!(
|
||||||
|
matches!(first, Ok(Some(_))),
|
||||||
|
matches!(line, Ok(Some(_))),
|
||||||
|
"solve_first_move and winning_line disagree on winnability"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn budget_is_passed_through_not_clamped() {
|
fn budget_is_passed_through_not_clamped() {
|
||||||
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
// This seed is Inconclusive at 1k states but Winnable at 5k — proving the
|
||||||
// budget reaches the solver unchanged.
|
// budget reaches the solver unchanged.
|
||||||
let easy = GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 1_000, 1_000);
|
let easy = GameState::solve_fresh_deal(
|
||||||
let medium =
|
0xD1FF_0000_0000_0012,
|
||||||
GameState::solve_fresh_deal(0xD1FF_0000_0000_0012, DrawStockConfig::DrawOne, 5_000, 5_000);
|
DrawStockConfig::DrawOne,
|
||||||
|
1_000,
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
let medium = GameState::solve_fresh_deal(
|
||||||
|
0xD1FF_0000_0000_0012,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
5_000,
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
assert!(easy.is_err());
|
assert!(easy.is_err());
|
||||||
assert!(matches!(medium, Ok(Some(_))));
|
assert!(matches!(medium, Ok(Some(_))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play a few real moves on a fresh deal and return the game.
|
||||||
|
fn game_with_some_moves(seed: u64) -> GameState {
|
||||||
|
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||||
|
for _ in 0..40 {
|
||||||
|
let instructions = game.possible_instructions();
|
||||||
|
let applied = instructions
|
||||||
|
.into_iter()
|
||||||
|
.find(|i| game.clone().apply_instruction(*i).is_ok())
|
||||||
|
.and_then(|i| game.apply_instruction(i).ok());
|
||||||
|
if applied.is_none() && game.draw().is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert!(
|
||||||
|
!game.instruction_history().is_empty(),
|
||||||
|
"test needs at least one recorded move"
|
||||||
|
);
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recording_round_trips_through_upstream_serde() {
|
||||||
|
let game = game_with_some_moves(51);
|
||||||
|
let recording = game.recording();
|
||||||
|
let json = serde_json::to_string(&recording).expect("serialize recording");
|
||||||
|
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
||||||
|
assert_eq!(recording, restored);
|
||||||
|
assert_eq!(recording.instructions(), game.instruction_history());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_recording_replays_to_identical_board_without_seed_dealing() {
|
||||||
|
let game = game_with_some_moves(145);
|
||||||
|
let recording = game.recording();
|
||||||
|
let json = serde_json::to_string(&recording).expect("serialize recording");
|
||||||
|
let restored: SessionRecording = serde_json::from_str(&json).expect("parse recording");
|
||||||
|
|
||||||
|
// Deliberately pass a DIFFERENT seed: the board must come from the
|
||||||
|
// recording, proving playback no longer depends on seed→deal mapping.
|
||||||
|
let (mut replayed, instructions) =
|
||||||
|
GameState::from_recording(&restored, 0xDEAD_BEEF, game.mode);
|
||||||
|
assert_eq!(replayed.draw_mode(), game.draw_mode());
|
||||||
|
for instruction in instructions {
|
||||||
|
replayed
|
||||||
|
.apply_instruction(instruction)
|
||||||
|
.expect("recorded instruction must replay cleanly");
|
||||||
|
}
|
||||||
|
for pile in [KlondikePile::Stock]
|
||||||
|
.into_iter()
|
||||||
|
.chain(crate::TABLEAUS.map(KlondikePile::Tableau))
|
||||||
|
.chain(crate::FOUNDATIONS.map(KlondikePile::Foundation))
|
||||||
|
{
|
||||||
|
assert_eq!(
|
||||||
|
replayed.pile(pile),
|
||||||
|
game.pile(pile),
|
||||||
|
"pile {pile:?} differs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(replayed.waste_cards(), game.waste_cards());
|
||||||
|
assert_eq!(replayed.move_count(), game.move_count());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// v6 save files carry the deal in the recording; the `seed` field is
|
||||||
|
/// presentation metadata. Corrupting it must not change the loaded board.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_load_ignores_seed_for_dealing() {
|
||||||
|
let game = game_with_some_moves(51);
|
||||||
|
let mut value = serde_json::to_value(&game).expect("serialise");
|
||||||
|
assert_eq!(value["schema_version"], 6);
|
||||||
|
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
|
||||||
|
let loaded: GameState =
|
||||||
|
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
|
||||||
|
assert_eq!(loaded.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(loaded.waste_cards(), game.waste_cards());
|
||||||
|
for index in 0..7 {
|
||||||
|
let t = GameState::tableau_from_index(index).expect("tableau index");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.pile(KlondikePile::Tableau(t)),
|
||||||
|
game.pile(KlondikePile::Tableau(t)),
|
||||||
|
"tableau {index} differs"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(loaded.move_count(), game.move_count());
|
||||||
|
assert_eq!(loaded.score(), game.score());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Legacy v5 files (seed + saved_moves, no recording) must still load
|
||||||
|
/// through the seed-dealt path until players resave as v6.
|
||||||
|
#[test]
|
||||||
|
fn v5_legacy_save_still_loads() {
|
||||||
|
let game = game_with_some_moves(145);
|
||||||
|
let v5 = serde_json::json!({
|
||||||
|
"draw_mode": game.draw_mode(),
|
||||||
|
"mode": game.mode,
|
||||||
|
"elapsed_seconds": 12,
|
||||||
|
"seed": game.seed,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 5,
|
||||||
|
"saved_moves": game.instruction_history(),
|
||||||
|
});
|
||||||
|
let loaded: GameState =
|
||||||
|
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
|
||||||
|
assert_eq!(loaded.move_count(), game.move_count());
|
||||||
|
assert_eq!(loaded.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(loaded.elapsed_seconds, 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A v6 file without the recording payload is malformed and must be
|
||||||
|
/// rejected with an error rather than silently re-dealt from the seed.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_without_recording_is_rejected() {
|
||||||
|
let game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||||
|
let mut value = serde_json::to_value(&game).expect("serialise");
|
||||||
|
value.as_object_mut().expect("object").remove("recording");
|
||||||
|
let err = serde_json::from_value::<GameState>(value)
|
||||||
|
.expect_err("v6 save without recording must fail");
|
||||||
|
assert!(err.to_string().contains("recording"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A recording whose instruction list is invalid for its own deal must
|
||||||
|
/// be rejected on load — tampered or corrupt files surface an error,
|
||||||
|
/// never a silently wrong board.
|
||||||
|
#[test]
|
||||||
|
fn v6_save_with_invalid_history_is_rejected() {
|
||||||
|
use klondike::{DstFoundation, Foundation, KlondikePile};
|
||||||
|
// A foundation move from an empty tableau is never legal on a fresh
|
||||||
|
// deal's first instruction slot for this source shape.
|
||||||
|
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
|
src: KlondikePile::Foundation(Foundation::Foundation1),
|
||||||
|
foundation: Foundation::Foundation2,
|
||||||
|
});
|
||||||
|
let recording =
|
||||||
|
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
|
||||||
|
let v6 = serde_json::json!({
|
||||||
|
"draw_mode": DrawStockConfig::DrawOne,
|
||||||
|
"mode": GameMode::Classic,
|
||||||
|
"elapsed_seconds": 0,
|
||||||
|
"seed": 7,
|
||||||
|
"take_from_foundation": true,
|
||||||
|
"schema_version": 6,
|
||||||
|
"recording": recording,
|
||||||
|
});
|
||||||
|
let err =
|
||||||
|
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
|
||||||
|
assert!(err.to_string().contains("invalid"), "got: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_recording_of_fresh_deal_returns_empty_instructions() {
|
||||||
|
let game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||||
|
let recording = game.recording();
|
||||||
|
assert!(recording.is_empty());
|
||||||
|
let (replayed, instructions) = GameState::from_recording(&recording, 7, game.mode);
|
||||||
|
assert!(instructions.is_empty());
|
||||||
|
assert_eq!(replayed.stock_cards(), game.stock_cards());
|
||||||
|
assert_eq!(replayed.draw_mode(), DrawStockConfig::DrawThree);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,12 +13,51 @@ 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::{DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau};
|
pub use klondike::{
|
||||||
|
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
|
||||||
|
};
|
||||||
|
|
||||||
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
// Solvability check API (delegates to `card_game::Session::solve`); replaces the
|
||||||
// former `solitaire_data::solver` wrapper module.
|
// former `solitaire_data::solver` wrapper module.
|
||||||
pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SolveOutcome};
|
pub use game_state::{
|
||||||
|
DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, SessionRecording, 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.
|
||||||
|
///
|
||||||
|
/// Canonical iteration source for `Foundation` — upstream `klondike` has no
|
||||||
|
/// `Foundation::ALL` (unlike `Suit::SUITS` / `Rank::RANKS` in `card_game`),
|
||||||
|
/// and inherent impls cannot be added to a foreign type, so the workspace
|
||||||
|
/// const lives here. Use this instead of hand-rolling `[Foundation; 4]`
|
||||||
|
/// arrays; scattered copies can silently diverge.
|
||||||
|
pub const FOUNDATIONS: [Foundation; 4] = [
|
||||||
|
Foundation::Foundation1,
|
||||||
|
Foundation::Foundation2,
|
||||||
|
Foundation::Foundation3,
|
||||||
|
Foundation::Foundation4,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// All seven tableau columns, in column order (left to right on screen).
|
||||||
|
///
|
||||||
|
/// Canonical iteration source for `Tableau` — see [`FOUNDATIONS`] for why
|
||||||
|
/// this lives here rather than upstream.
|
||||||
|
pub const TABLEAUS: [Tableau; 7] = [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
];
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod proptest_tests;
|
mod proptest_tests;
|
||||||
|
|||||||
@@ -41,13 +41,20 @@ fn all_cards(game: &GameState) -> Vec<Card> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for t in &tableaux {
|
for t in &tableaux {
|
||||||
cards.extend(game.pile(KlondikePile::Tableau(*t)).iter().map(|(c, _)| c.clone()));
|
cards.extend(
|
||||||
|
game.pile(KlondikePile::Tableau(*t))
|
||||||
|
.iter()
|
||||||
|
.map(|(c, _)| c.clone()),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
cards
|
cards
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
fn draw_mode_strategy() -> impl Strategy<Value = DrawStockConfig> {
|
||||||
prop_oneof![Just(DrawStockConfig::DrawOne), Just(DrawStockConfig::DrawThree)]
|
prop_oneof![
|
||||||
|
Just(DrawStockConfig::DrawOne),
|
||||||
|
Just(DrawStockConfig::DrawThree)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
/// Apply a sequence of random actions to a game, silently ignoring errors.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -24,6 +24,9 @@ uuid = { workspace = true }
|
|||||||
dirs = { workspace = true }
|
dirs = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
|
# Theme-store downloads are verified against the catalog's SHA-256
|
||||||
|
# before the archive is handed to the engine's theme importer.
|
||||||
|
sha2 = { workspace = true }
|
||||||
|
|
||||||
# `keyring-core` is the typed Entry/Error API used by
|
# `keyring-core` is the typed Entry/Error API used by
|
||||||
# `auth_tokens`. The crate's own dependency tree pulls in
|
# `auth_tokens`. The crate's own dependency tree pulls in
|
||||||
@@ -47,6 +50,10 @@ sqlx = { workspace = true }
|
|||||||
jsonwebtoken = { workspace = true }
|
jsonwebtoken = { workspace = true }
|
||||||
uuid = { workspace = true }
|
uuid = { workspace = true }
|
||||||
chrono = { workspace = true }
|
chrono = { workspace = true }
|
||||||
|
# theme_store_round_trip builds a theme zip in a tempdir for the
|
||||||
|
# in-process store server.
|
||||||
|
zip = { workspace = true }
|
||||||
|
tempfile = { workspace = true }
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|||||||
+18
-12
@@ -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;
|
||||||
@@ -134,13 +133,17 @@ pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for};
|
|||||||
pub mod difficulty_seeds;
|
pub mod difficulty_seeds;
|
||||||
pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
||||||
|
|
||||||
|
pub mod transfer;
|
||||||
|
pub use transfer::{
|
||||||
|
BundleError, DATA_BUNDLE_SCHEMA_VERSION, DataBundle, data_bundle_path, load_bundle_from,
|
||||||
|
save_bundle_to,
|
||||||
|
};
|
||||||
|
|
||||||
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,15 +155,18 @@ 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;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
pub use sync_client::{SolitaireServerClient, provider_for_backend};
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod theme_store_client;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use theme_store_client::{ThemeStoreClient, ThemeStoreError};
|
||||||
|
|
||||||
pub mod replay;
|
pub mod replay;
|
||||||
pub use replay::{
|
pub use replay::{
|
||||||
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
REPLAY_HISTORY_CAP, REPLAY_HISTORY_SCHEMA_VERSION, REPLAY_SCHEMA_VERSION, Replay,
|
||||||
|
|||||||
@@ -12,22 +12,24 @@
|
|||||||
//! carries any other version so older replays are silently dropped instead
|
//! carries any other version so older replays are silently dropped instead
|
||||||
//! of crashing the loader.
|
//! of crashing the loader.
|
||||||
//!
|
//!
|
||||||
//! The recording is intentionally minimal — only the
|
//! The payload is a [`SessionRecording`](solitaire_core::SessionRecording):
|
||||||
//! [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) inputs that
|
//! the upstream `card_game` session serialisation, which stores the dealt
|
||||||
//! successfully advanced the game. `Undo` is **not** recorded: a replay
|
//! board **explicitly** plus the ordered instruction list. `Undo` is not
|
||||||
//! represents the canonical path the player ultimately took to win, so
|
//! recorded: a replay represents the canonical path the player ultimately
|
||||||
//! backed-out missteps simply do not appear in the move list. The starting
|
//! took to win, so backed-out missteps simply do not appear (the session
|
||||||
//! deal is not stored either — the [`seed`](Replay::seed) +
|
//! pops them from its history).
|
||||||
//! [`draw_mode`](Replay::draw_mode) + [`mode`](Replay::mode) are sufficient
|
//!
|
||||||
//! for `GameState::new_with_mode` to rebuild the identical layout.
|
//! Storing the deal (rather than re-dealing from [`seed`](Replay::seed) at
|
||||||
|
//! playback time, as schemas ≤ v3 did) makes replays immune to seed→deal
|
||||||
|
//! mapping drift across RNG or upstream-crate upgrades — the exact failure
|
||||||
|
//! that silently broke every pre-upgrade replay. `seed`, `draw_mode`, and
|
||||||
|
//! `mode` remain as presentation/indexing metadata only.
|
||||||
//!
|
//!
|
||||||
//! Each recorded move is the player's atomic *input*, not its outcome.
|
//! Each recorded move is the player's atomic *input*, not its outcome.
|
||||||
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
//! `KlondikeInstruction::RotateStock` covers every click on the stock pile;
|
||||||
//! the engine resolves draw-vs-recycle deterministically from the current
|
//! the engine resolves draw-vs-recycle deterministically from the current
|
||||||
//! stock state during playback, so the same input always produces the same
|
//! stock state during playback, so the same input always produces the same
|
||||||
//! effect on the same starting deal. Runtime-only pile-position types are
|
//! effect on the same starting deal.
|
||||||
//! never serialised — the instruction itself serialises via its compact
|
|
||||||
//! upstream serde representation.
|
|
||||||
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::io;
|
use std::io;
|
||||||
@@ -35,7 +37,7 @@ use std::path::{Path, PathBuf};
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||||
|
|
||||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||||
@@ -77,13 +79,19 @@ fn history_schema_v0() -> u32 {
|
|||||||
/// variants which carried the *outcome* of a stock interaction rather
|
/// variants which carried the *outcome* of a stock interaction rather
|
||||||
/// than the player's atomic input.
|
/// than the player's atomic input.
|
||||||
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
/// - v2: `Draw` + `Recycle` collapsed into a single `StockClick` variant.
|
||||||
/// - v3 (current): the bespoke `ReplayMove` serde mirror was dropped. Moves
|
/// - v3: the bespoke `ReplayMove` serde mirror was dropped. Moves
|
||||||
/// are now stored directly as upstream
|
/// were stored directly as upstream `KlondikeInstruction` (compact
|
||||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) (compact
|
/// int serde); `StockClick` became `RotateStock`. Pile-position types are
|
||||||
/// int serde); `StockClick` is now `RotateStock`. Pile-position types are
|
/// runtime-only and are never serialised. The starting deal was still
|
||||||
/// runtime-only and are never serialised. v1/v2 files fail to deserialise
|
/// rebuilt from the seed at playback time.
|
||||||
/// and are discarded by the loader.
|
/// - v4 (current): the bare `moves` list was replaced by a
|
||||||
pub const REPLAY_SCHEMA_VERSION: u32 = 3;
|
/// [`SessionRecording`](solitaire_core::SessionRecording) — the upstream
|
||||||
|
/// `card_game` session serialisation carrying the dealt board explicitly
|
||||||
|
/// plus the instruction list. Playback no longer re-deals from the seed,
|
||||||
|
/// so replays survive RNG/upstream upgrades that change the seed→deal
|
||||||
|
/// mapping (which invalidated every v3 replay). v1–v3 files fail the
|
||||||
|
/// version gate and are discarded by the loader.
|
||||||
|
pub const REPLAY_SCHEMA_VERSION: u32 = 4;
|
||||||
|
|
||||||
/// Default value for [`Replay::schema_version`] when deserialising files
|
/// Default value for [`Replay::schema_version`] when deserialising files
|
||||||
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
/// that pre-date the field. Any value other than [`REPLAY_SCHEMA_VERSION`]
|
||||||
@@ -94,9 +102,10 @@ fn schema_v0() -> u32 {
|
|||||||
|
|
||||||
/// A complete recording of a single winning game.
|
/// A complete recording of a single winning game.
|
||||||
///
|
///
|
||||||
/// Replays are reconstructed by rebuilding a fresh
|
/// Replays are reconstructed via
|
||||||
/// `GameState::new_with_mode(seed, draw_mode, mode)` and applying the
|
/// `GameState::from_recording(&replay.recording, replay.seed, replay.mode)`,
|
||||||
/// [`moves`](Self::moves) in order. The presentation fields
|
/// which rebuilds the recorded deal directly and returns the instruction
|
||||||
|
/// list to step through. The presentation fields
|
||||||
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
/// ([`time_seconds`](Self::time_seconds), [`final_score`](Self::final_score),
|
||||||
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
/// [`recorded_at`](Self::recorded_at)) drive the Stats UI caption such as
|
||||||
/// "Replay (2:14 win on 2026-05-02)".
|
/// "Replay (2:14 win on 2026-05-02)".
|
||||||
@@ -105,8 +114,9 @@ pub struct Replay {
|
|||||||
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
/// Schema version. See [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default = "schema_v0")]
|
#[serde(default = "schema_v0")]
|
||||||
pub schema_version: u32,
|
pub schema_version: u32,
|
||||||
/// Seed used for the deal — replay rasterises the deck via
|
/// Seed the recorded game was originally dealt from. Presentation /
|
||||||
/// `GameState::new_with_mode(seed, draw_mode, mode)`.
|
/// indexing metadata only — playback rebuilds the board from
|
||||||
|
/// [`recording`](Self::recording), never by re-dealing this seed.
|
||||||
pub seed: u64,
|
pub seed: u64,
|
||||||
/// Draw mode the recorded game was played in.
|
/// Draw mode the recorded game was played in.
|
||||||
pub draw_mode: DrawStockConfig,
|
pub draw_mode: DrawStockConfig,
|
||||||
@@ -119,11 +129,10 @@ pub struct Replay {
|
|||||||
pub final_score: i32,
|
pub final_score: i32,
|
||||||
/// ISO-8601 date the win was recorded.
|
/// ISO-8601 date the win was recorded.
|
||||||
pub recorded_at: NaiveDate,
|
pub recorded_at: NaiveDate,
|
||||||
/// Ordered move list. Each entry is the atomic
|
/// The dealt board plus the ordered instruction list, serialised via the
|
||||||
/// [`KlondikeInstruction`](solitaire_core::KlondikeInstruction) the player
|
/// upstream `card_game` session serializers. Self-contained: playback
|
||||||
/// issued, replayable against a fresh `GameState` constructed from the
|
/// needs nothing else to reproduce the game move-for-move.
|
||||||
/// seed via `GameState::apply_instruction`.
|
pub recording: SessionRecording,
|
||||||
pub moves: Vec<KlondikeInstruction>,
|
|
||||||
/// Public share URL for this replay on the active sync backend, set
|
/// Public share URL for this replay on the active sync backend, set
|
||||||
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
/// by `sync_plugin::poll_replay_upload_result` when the upload
|
||||||
/// task resolves. `None` when the player won on a local-only
|
/// task resolves. `None` when the player won on a local-only
|
||||||
@@ -133,11 +142,12 @@ pub struct Replay {
|
|||||||
/// [`REPLAY_SCHEMA_VERSION`].
|
/// [`REPLAY_SCHEMA_VERSION`].
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub share_url: Option<String>,
|
pub share_url: Option<String>,
|
||||||
/// Index into [`moves`](Self::moves) of the move that triggered
|
/// Index into the [`recording`](Self::recording)'s instruction list
|
||||||
/// the win condition (i.e. completed the last foundation pile).
|
/// of the move that triggered the win condition (i.e. completed the
|
||||||
|
/// last foundation pile).
|
||||||
///
|
///
|
||||||
/// For replays recorded by the live engine this is always
|
/// For replays recorded by the live engine this is always
|
||||||
/// `Some(moves.len() - 1)` because recording freezes on win — but
|
/// `Some(recording.len() - 1)` because recording freezes on win — but
|
||||||
/// the field is stored explicitly so the playback UI can read it
|
/// the field is stored explicitly so the playback UI can read it
|
||||||
/// directly without re-deriving "the last move was the win" each
|
/// directly without re-deriving "the last move was the win" each
|
||||||
/// time, and to leave room for future recording semantics that
|
/// time, and to leave room for future recording semantics that
|
||||||
@@ -172,7 +182,7 @@ impl Replay {
|
|||||||
time_seconds: u64,
|
time_seconds: u64,
|
||||||
final_score: i32,
|
final_score: i32,
|
||||||
recorded_at: NaiveDate,
|
recorded_at: NaiveDate,
|
||||||
moves: Vec<KlondikeInstruction>,
|
recording: SessionRecording,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
schema_version: REPLAY_SCHEMA_VERSION,
|
schema_version: REPLAY_SCHEMA_VERSION,
|
||||||
@@ -182,7 +192,7 @@ impl Replay {
|
|||||||
time_seconds,
|
time_seconds,
|
||||||
final_score,
|
final_score,
|
||||||
recorded_at,
|
recorded_at,
|
||||||
moves,
|
recording,
|
||||||
share_url: None,
|
share_url: None,
|
||||||
win_move_index: None,
|
win_move_index: None,
|
||||||
}
|
}
|
||||||
@@ -193,7 +203,7 @@ impl Replay {
|
|||||||
/// [`Replay::new`]:
|
/// [`Replay::new`]:
|
||||||
///
|
///
|
||||||
/// ```ignore
|
/// ```ignore
|
||||||
/// let replay = Replay::new(...).with_win_move_index(Some(recording.moves.len() - 1));
|
/// let replay = Replay::new(...).with_win_move_index(recording.len().checked_sub(1));
|
||||||
/// ```
|
/// ```
|
||||||
///
|
///
|
||||||
/// `None` is a valid input — useful for tests that don't care about
|
/// `None` is a valid input — useful for tests that don't care about
|
||||||
@@ -430,7 +440,8 @@ pub fn migrate_legacy_latest_replay(latest_path: &Path, history_path: &Path) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use klondike::{
|
use klondike::{
|
||||||
DstFoundation, DstTableau, Foundation, KlondikePile, KlondikePileStack, Tableau,
|
DstFoundation, DstTableau, Foundation, KlondikeInstruction, KlondikePile,
|
||||||
|
KlondikePileStack, Tableau,
|
||||||
};
|
};
|
||||||
use std::env;
|
use std::env;
|
||||||
|
|
||||||
@@ -447,18 +458,22 @@ mod tests {
|
|||||||
134,
|
134,
|
||||||
5_120,
|
5_120,
|
||||||
date,
|
date,
|
||||||
vec![
|
SessionRecording::from_instructions_unchecked(
|
||||||
KlondikeInstruction::RotateStock,
|
12345,
|
||||||
KlondikeInstruction::DstTableau(DstTableau {
|
DrawStockConfig::DrawThree,
|
||||||
src: KlondikePileStack::Stock,
|
[
|
||||||
tableau: Tableau::Tableau4,
|
KlondikeInstruction::RotateStock,
|
||||||
}),
|
KlondikeInstruction::DstTableau(DstTableau {
|
||||||
KlondikeInstruction::RotateStock,
|
src: KlondikePileStack::Stock,
|
||||||
KlondikeInstruction::DstFoundation(DstFoundation {
|
tableau: Tableau::Tableau4,
|
||||||
src: KlondikePile::Tableau(Tableau::Tableau4),
|
}),
|
||||||
foundation: Foundation::Foundation1,
|
KlondikeInstruction::RotateStock,
|
||||||
}),
|
KlondikeInstruction::DstFoundation(DstFoundation {
|
||||||
],
|
src: KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
|
foundation: Foundation::Foundation1,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -518,25 +533,22 @@ mod tests {
|
|||||||
/// rolling history wiped on the v0.19.0 update.
|
/// rolling history wiped on the v0.19.0 update.
|
||||||
#[test]
|
#[test]
|
||||||
fn replay_loads_when_share_url_field_is_absent() {
|
fn replay_loads_when_share_url_field_is_absent() {
|
||||||
let pre_v019_json = format!(
|
// Build a current-schema JSON object, then strip the optional
|
||||||
r#"{{
|
// fields to simulate a file written before they existed.
|
||||||
"schema_version": {schema},
|
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||||
"seed": 1,
|
let obj = value.as_object_mut().expect("replay serialises as object");
|
||||||
"draw_mode": "DrawOne",
|
obj.remove("share_url");
|
||||||
"mode": "Classic",
|
obj.remove("win_move_index");
|
||||||
"time_seconds": 60,
|
let parsed: Replay = serde_json::from_value(value)
|
||||||
"final_score": 100,
|
.expect("replay JSON without optional fields must still deserialise");
|
||||||
"recorded_at": "2025-01-01",
|
|
||||||
"moves": []
|
|
||||||
}}"#,
|
|
||||||
schema = REPLAY_SCHEMA_VERSION,
|
|
||||||
);
|
|
||||||
let parsed: Replay = serde_json::from_str(&pre_v019_json)
|
|
||||||
.expect("pre-v0.19.0 replay JSON must still deserialise");
|
|
||||||
assert!(
|
assert!(
|
||||||
parsed.share_url.is_none(),
|
parsed.share_url.is_none(),
|
||||||
"missing share_url field must default to None",
|
"missing share_url field must default to None",
|
||||||
);
|
);
|
||||||
|
assert!(
|
||||||
|
parsed.win_move_index.is_none(),
|
||||||
|
"missing win_move_index field must default to None",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Atomic-write contract — `.tmp` must not be left behind after
|
/// Atomic-write contract — `.tmp` must not be left behind after
|
||||||
@@ -588,7 +600,11 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
id,
|
id,
|
||||||
date,
|
date,
|
||||||
vec![KlondikeInstruction::RotateStock],
|
SessionRecording::from_instructions_unchecked(
|
||||||
|
id as u64,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
[KlondikeInstruction::RotateStock],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -824,22 +840,14 @@ mod tests {
|
|||||||
let path = tmp_path("legacy_no_win_move_index");
|
let path = tmp_path("legacy_no_win_move_index");
|
||||||
let _ = fs::remove_file(&path);
|
let _ = fs::remove_file(&path);
|
||||||
|
|
||||||
// Hand-rolled minimal current-schema replay JSON with no
|
// Current-schema replay JSON with the win_move_index field stripped —
|
||||||
// win_move_index field — the additive field must still default to None.
|
// the additive field must still default to None.
|
||||||
let no_field = format!(
|
let mut value = serde_json::to_value(sample_replay()).expect("serialise sample");
|
||||||
r#"{{
|
value
|
||||||
"schema_version": {schema},
|
.as_object_mut()
|
||||||
"seed": 1,
|
.expect("replay serialises as object")
|
||||||
"draw_mode": "DrawOne",
|
.remove("win_move_index");
|
||||||
"mode": "Classic",
|
fs::write(&path, serde_json::to_string(&value).expect("to_string")).expect("write fixture");
|
||||||
"time_seconds": 60,
|
|
||||||
"final_score": 100,
|
|
||||||
"recorded_at": "2026-05-02",
|
|
||||||
"moves": []
|
|
||||||
}}"#,
|
|
||||||
schema = REPLAY_SCHEMA_VERSION,
|
|
||||||
);
|
|
||||||
fs::write(&path, no_field).expect("write fixture");
|
|
||||||
|
|
||||||
let loaded = load_latest_replay_from(&path).expect("load");
|
let loaded = load_latest_replay_from(&path).expect("load");
|
||||||
assert_eq!(loaded.win_move_index, None);
|
assert_eq!(loaded.win_move_index, None);
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
use solitaire_core::{
|
||||||
|
DrawStockConfig,
|
||||||
|
game_state::{DifficultyLevel, GameMode},
|
||||||
|
};
|
||||||
|
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
@@ -151,6 +154,14 @@ pub struct Settings {
|
|||||||
/// `#[serde(default)]`.
|
/// `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reduce_motion_mode: bool,
|
pub reduce_motion_mode: bool,
|
||||||
|
/// When `true`, replaces translucent "glass" chrome (the floating
|
||||||
|
/// touch tab bar) with opaque fills so table content behind it can
|
||||||
|
/// never reduce label/icon contrast. The accessibility counterpart
|
||||||
|
/// of iOS "Reduce Transparency". Older `settings.json` files written
|
||||||
|
/// before this field existed deserialize cleanly to `false` thanks
|
||||||
|
/// to `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reduce_transparency_mode: bool,
|
||||||
/// Window size and screen position to restore on next launch. `None`
|
/// Window size and screen position to restore on next launch. `None`
|
||||||
/// means "use platform defaults" — set on first run, then populated
|
/// means "use platform defaults" — set on first run, then populated
|
||||||
/// as the player resizes / moves the window. Older `settings.json`
|
/// as the player resizes / moves the window. Older `settings.json`
|
||||||
@@ -161,8 +172,10 @@ pub struct Settings {
|
|||||||
/// Identifier of the active card-art theme. Matches `meta.id` from
|
/// Identifier of the active card-art theme. Matches `meta.id` from
|
||||||
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
/// the theme's `theme.ron` manifest. `"dark"` and `"classic"` are
|
||||||
/// always present; user-supplied themes register under their own ids.
|
/// always present; user-supplied themes register under their own ids.
|
||||||
/// Older `settings.json` files that stored `"default"` or `"classic"`
|
/// Older `settings.json` files that stored `"default"` (the
|
||||||
/// are migrated to `"dark"` by [`Settings::sanitized`].
|
/// pre-rename id of the dark theme) are migrated to `"dark"` by
|
||||||
|
/// [`Settings::sanitized`]; `"classic"` is a valid player choice
|
||||||
|
/// and is never rewritten.
|
||||||
#[serde(default = "default_theme_id")]
|
#[serde(default = "default_theme_id")]
|
||||||
pub selected_theme_id: String,
|
pub selected_theme_id: String,
|
||||||
/// Set to `true` once the achievement-onboarding info-toast has been
|
/// Set to `true` once the achievement-onboarding info-toast has been
|
||||||
@@ -246,6 +259,38 @@ pub struct Settings {
|
|||||||
/// cleanly to `None` via `#[serde(default)]`.
|
/// cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_difficulty: Option<DifficultyLevel>,
|
pub last_difficulty: Option<DifficultyLevel>,
|
||||||
|
/// Mode of the last game the player launched from the home overlay.
|
||||||
|
/// The home hero "New Game" button replays this mode with one tap.
|
||||||
|
/// Older `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_mode: GameMode,
|
||||||
|
/// The release version whose "What's new" card the player has already
|
||||||
|
/// seen (e.g. `"0.46.0"`). Empty on installs that predate the card,
|
||||||
|
/// which correctly reads as "there is news to show" after an upgrade;
|
||||||
|
/// fresh installs stamp it silently when onboarding completes. Older
|
||||||
|
/// `settings.json` files deserialize cleanly to `""` via
|
||||||
|
/// `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_seen_whats_new: String,
|
||||||
|
/// `true` once the one-shot "Stuck? Try a hint" contextual tip has
|
||||||
|
/// fired (or been suppressed as unnecessary). Phase I teach: fired
|
||||||
|
/// by the situation, shown once, like `shown_achievement_onboarding`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_stall_hint_tip: bool,
|
||||||
|
/// `true` once the one-shot radial-menu contextual tip has fired —
|
||||||
|
/// or the player has already opened the radial menu on their own,
|
||||||
|
/// which marks the tip as unnecessary without showing it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_radial_menu_tip: bool,
|
||||||
|
/// Global UI scale multiplier applied to all UI chrome (HUD, modals,
|
||||||
|
/// action bar) — the table itself stays window-fit via
|
||||||
|
/// `compute_layout`. Cycles through 0.9 / 1.0 / 1.15 / 1.3 in
|
||||||
|
/// Settings → Accessibility; clamped to `[UI_SCALE_MIN,
|
||||||
|
/// UI_SCALE_MAX]` by [`Settings::sanitized`]. Older `settings.json`
|
||||||
|
/// files deserialize cleanly to `1.0` via the serde default.
|
||||||
|
#[serde(default = "default_ui_scale")]
|
||||||
|
pub ui_scale: f32,
|
||||||
/// Custom public name displayed on the leaderboard. When `None`, the
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
/// before submission. Older `settings.json` files written before this
|
/// before submission. Older `settings.json` files written before this
|
||||||
@@ -354,6 +399,16 @@ fn default_replay_move_interval_secs() -> f32 {
|
|||||||
0.45
|
0.45
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lowest / highest UI scale the settings row offers; anything outside
|
||||||
|
/// (hand-edited settings.json) clamps here on load.
|
||||||
|
pub const UI_SCALE_MIN: f32 = 0.9;
|
||||||
|
/// See [`UI_SCALE_MIN`].
|
||||||
|
pub const UI_SCALE_MAX: f32 = 1.3;
|
||||||
|
|
||||||
|
fn default_ui_scale() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_matomo_site_id() -> u32 {
|
fn default_matomo_site_id() -> u32 {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
@@ -404,6 +459,7 @@ impl Default for Settings {
|
|||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
high_contrast_mode: false,
|
high_contrast_mode: false,
|
||||||
reduce_motion_mode: false,
|
reduce_motion_mode: false,
|
||||||
|
reduce_transparency_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
selected_theme_id: default_theme_id(),
|
selected_theme_id: default_theme_id(),
|
||||||
shown_achievement_onboarding: false,
|
shown_achievement_onboarding: false,
|
||||||
@@ -413,6 +469,11 @@ impl Default for Settings {
|
|||||||
disable_smart_default_size: false,
|
disable_smart_default_size: false,
|
||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
|
last_mode: GameMode::Classic,
|
||||||
|
last_seen_whats_new: String::new(),
|
||||||
|
shown_stall_hint_tip: false,
|
||||||
|
shown_radial_menu_tip: false,
|
||||||
|
ui_scale: default_ui_scale(),
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
leaderboard_opted_in: false,
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
@@ -448,6 +509,7 @@ impl Settings {
|
|||||||
replay_move_interval_secs: self
|
replay_move_interval_secs: self
|
||||||
.replay_move_interval_secs
|
.replay_move_interval_secs
|
||||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||||
|
ui_scale: self.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX),
|
||||||
selected_theme_id,
|
selected_theme_id,
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -505,7 +477,7 @@ mod tests {
|
|||||||
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets
|
||||||
/// to 0 on load because only the forward instruction history is persisted.
|
/// to 0 on load because only the forward instruction history is persisted.
|
||||||
#[test]
|
#[test]
|
||||||
fn game_state_v5_mid_game_round_trip() {
|
fn game_state_v6_mid_game_round_trip() {
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::KlondikeInstruction;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
@@ -546,19 +518,24 @@ mod tests {
|
|||||||
|
|
||||||
save_game_state_to(&path, &gs).expect("save");
|
save_game_state_to(&path, &gs).expect("save");
|
||||||
|
|
||||||
// Verify the file carries the v5 schema marker.
|
// Verify the file carries the v6 schema marker and the recording.
|
||||||
let json = fs::read_to_string(&path).expect("read json");
|
let json = fs::read_to_string(&path).expect("read json");
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse saved json");
|
||||||
|
assert_eq!(
|
||||||
|
parsed["schema_version"], 6,
|
||||||
|
"saved file must use schema version 6",
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
json.contains("\"schema_version\"") && json.contains('5'),
|
parsed["recording"].is_object(),
|
||||||
"saved file must use schema version 5",
|
"saved file must embed the session recording",
|
||||||
);
|
);
|
||||||
|
|
||||||
let loaded = load_game_state_from(&path)
|
let loaded =
|
||||||
.expect("a valid in-progress game must load without error");
|
load_game_state_from(&path).expect("a valid in-progress game must load without error");
|
||||||
|
|
||||||
// The forward instruction history round-trips, so the reconstructed board
|
// The forward instruction history round-trips, so the reconstructed board
|
||||||
// re-serialises to byte-identical JSON.
|
// re-serialises to byte-identical JSON.
|
||||||
let path_reload = gs_path("v5_mid_game_reload");
|
let path_reload = gs_path("v6_mid_game_reload");
|
||||||
let _ = fs::remove_file(&path_reload);
|
let _ = fs::remove_file(&path_reload);
|
||||||
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -569,7 +546,11 @@ mod tests {
|
|||||||
|
|
||||||
// Derived board reads match the live game (move count + recycle count are
|
// Derived board reads match the live game (move count + recycle count are
|
||||||
// both rebuilt from the replayed forward history).
|
// both rebuilt from the replayed forward history).
|
||||||
assert_eq!(loaded.move_count(), gs.move_count(), "move_count round-trips");
|
assert_eq!(
|
||||||
|
loaded.move_count(),
|
||||||
|
gs.move_count(),
|
||||||
|
"move_count round-trips"
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
loaded.recycle_count(),
|
loaded.recycle_count(),
|
||||||
gs.recycle_count(),
|
gs.recycle_count(),
|
||||||
|
|||||||
@@ -77,6 +77,11 @@ pub struct SolitaireServerClient {
|
|||||||
username: String,
|
username: String,
|
||||||
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
/// Shared `reqwest` client (keeps connection pools alive across calls).
|
||||||
client: reqwest::Client,
|
client: reqwest::Client,
|
||||||
|
/// Serialises token refreshes. The server rotates refresh tokens and each
|
||||||
|
/// is single-use, so two overlapping 401-retries must not both spend one:
|
||||||
|
/// the loser's (already-consumed) token would be rejected and surface as
|
||||||
|
/// a spurious "session expired" to the player. See [`Self::refresh_token`].
|
||||||
|
refresh_lock: tokio::sync::Mutex<()>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -90,6 +95,7 @@ impl SolitaireServerClient {
|
|||||||
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||||
username: username.into(),
|
username: username.into(),
|
||||||
client: reqwest::Client::new(),
|
client: reqwest::Client::new(),
|
||||||
|
refresh_lock: tokio::sync::Mutex::new(()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,7 +178,27 @@ impl SolitaireServerClient {
|
|||||||
/// The server rotates refresh tokens on each call: the response includes a
|
/// The server rotates refresh tokens on each call: the response includes a
|
||||||
/// new refresh token that replaces the old one. Both tokens are persisted
|
/// new refresh token that replaces the old one. Both tokens are persisted
|
||||||
/// to the OS keychain on success.
|
/// to the OS keychain on success.
|
||||||
async fn refresh_token(&self) -> Result<(), SyncError> {
|
///
|
||||||
|
/// `stale_access` is the access token that just earned the caller a 401.
|
||||||
|
/// Refreshes are serialised behind [`Self::refresh_lock`]: with single-use
|
||||||
|
/// rotated refresh tokens, two overlapping 401-retries (e.g. a replay
|
||||||
|
/// upload racing a manual sync) must not both call `/api/auth/refresh` —
|
||||||
|
/// the second call would present an already-consumed token, get rejected,
|
||||||
|
/// and force a re-login for no user-visible reason. Whoever loses the lock
|
||||||
|
/// race checks whether the stored access token has already moved past
|
||||||
|
/// `stale_access`; if so the refresh already happened and there is nothing
|
||||||
|
/// left to do.
|
||||||
|
async fn refresh_token(&self, stale_access: &str) -> Result<(), SyncError> {
|
||||||
|
let _guard = self.refresh_lock.lock().await;
|
||||||
|
|
||||||
|
if let Ok(current) = load_access_token(&self.username)
|
||||||
|
&& current != stale_access
|
||||||
|
{
|
||||||
|
// Another task refreshed while we waited on the lock; retry with
|
||||||
|
// the token it stored rather than spending the new refresh token.
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
let old_refresh =
|
let old_refresh =
|
||||||
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
load_refresh_token(&self.username).map_err(|e| SyncError::Auth(e.to_string()))?;
|
||||||
|
|
||||||
@@ -231,7 +257,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
// Token expired — refresh and retry once.
|
// Token expired — refresh and retry once.
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -264,7 +290,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
// Token expired — refresh and retry once.
|
// Token expired — refresh and retry once.
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -331,7 +357,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -366,7 +392,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -406,7 +432,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -446,7 +472,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -480,7 +506,7 @@ impl SyncProvider for SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
@@ -542,7 +568,7 @@ impl SolitaireServerClient {
|
|||||||
.map_err(|e| SyncError::Network(e.to_string()))?;
|
.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||||
|
|
||||||
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
|
||||||
self.refresh_token().await?;
|
self.refresh_token(&token).await?;
|
||||||
let new_token = self.access_token()?;
|
let new_token = self.access_token()?;
|
||||||
let resp = self
|
let resp = self
|
||||||
.client
|
.client
|
||||||
|
|||||||
@@ -0,0 +1,240 @@
|
|||||||
|
//! HTTP client for the server's theme-store endpoints.
|
||||||
|
//!
|
||||||
|
//! Fetches the catalog (`GET /api/themes`) and downloads theme
|
||||||
|
//! archives, verifying each download's size and SHA-256 against the
|
||||||
|
//! catalog entry before returning the bytes. The caller (the engine's
|
||||||
|
//! theme-store UI) hands verified bytes to the theme importer, which
|
||||||
|
//! independently re-validates the archive's structure — the store
|
||||||
|
//! pipeline never trusts a byte the importer hasn't checked.
|
||||||
|
//!
|
||||||
|
//! Native-only: gated out on wasm32 alongside the other `reqwest`
|
||||||
|
//! consumers in this crate.
|
||||||
|
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use solitaire_sync::{ThemeCatalogEntry, ThemeCatalogResponse};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
/// Hard cap on a theme archive download, matching the engine
|
||||||
|
/// importer's `MAX_ARCHIVE_BYTES` — anything larger can never import,
|
||||||
|
/// so downloading it is pure waste.
|
||||||
|
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Hard cap on a preview PNG download — previews are small decorative
|
||||||
|
/// thumbnails; anything past this is a misconfigured server.
|
||||||
|
pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024;
|
||||||
|
|
||||||
|
/// Errors surfaced by [`ThemeStoreClient`].
|
||||||
|
#[derive(Debug, Error)]
|
||||||
|
pub enum ThemeStoreError {
|
||||||
|
/// The request could not be sent or the response body not read.
|
||||||
|
#[error("network error: {0}")]
|
||||||
|
Network(String),
|
||||||
|
/// The server answered with a non-success status code.
|
||||||
|
#[error("server returned HTTP {0}")]
|
||||||
|
Http(u16),
|
||||||
|
/// The catalog JSON did not parse.
|
||||||
|
#[error("malformed catalog: {0}")]
|
||||||
|
MalformedCatalog(String),
|
||||||
|
/// The archive is larger than [`MAX_THEME_DOWNLOAD_BYTES`] or its
|
||||||
|
/// catalog-declared size.
|
||||||
|
#[error("archive size {got} exceeds the expected {expected} bytes")]
|
||||||
|
Oversized { expected: u64, got: u64 },
|
||||||
|
/// The downloaded bytes do not hash to the catalog's SHA-256 —
|
||||||
|
/// the file changed on the server or was corrupted in transit.
|
||||||
|
#[error("archive checksum mismatch (expected {expected}, got {got})")]
|
||||||
|
ChecksumMismatch { expected: String, got: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Client for one server's theme store.
|
||||||
|
///
|
||||||
|
/// Unauthenticated: the catalog and downloads are public endpoints,
|
||||||
|
/// so unlike `SolitaireServerClient` there is no token handling.
|
||||||
|
pub struct ThemeStoreClient {
|
||||||
|
/// Base URL of the server, trailing slash stripped.
|
||||||
|
base_url: String,
|
||||||
|
client: reqwest::Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ThemeStoreClient {
|
||||||
|
/// Construct a client for the server at `base_url`
|
||||||
|
/// (e.g. `"https://solitaire.example.com"`).
|
||||||
|
pub fn new(base_url: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
base_url: base_url.into().trim_end_matches('/').to_owned(),
|
||||||
|
client: reqwest::Client::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the theme catalog, sorted by display name (server-side).
|
||||||
|
pub async fn fetch_catalog(&self) -> Result<Vec<ThemeCatalogEntry>, ThemeStoreError> {
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/themes", self.base_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let catalog: ThemeCatalogResponse = resp
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::MalformedCatalog(e.to_string()))?;
|
||||||
|
Ok(catalog.themes)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Download `entry`'s archive and verify it against the catalog's
|
||||||
|
/// size and SHA-256. Returns the verified `.zip` bytes, ready for
|
||||||
|
/// the engine's theme importer.
|
||||||
|
pub async fn download_theme(
|
||||||
|
&self,
|
||||||
|
entry: &ThemeCatalogEntry,
|
||||||
|
) -> Result<Vec<u8>, ThemeStoreError> {
|
||||||
|
// The catalog itself could name an absurd size; refuse before
|
||||||
|
// buffering anything.
|
||||||
|
if entry.size_bytes > MAX_THEME_DOWNLOAD_BYTES {
|
||||||
|
return Err(ThemeStoreError::Oversized {
|
||||||
|
expected: MAX_THEME_DOWNLOAD_BYTES,
|
||||||
|
got: entry.size_bytes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}{}", self.base_url, entry.download_url))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
verify_archive(&bytes, entry)?;
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch the preview PNG the catalog advertises for `entry`.
|
||||||
|
///
|
||||||
|
/// Returns `Http(404)` when the entry carries no `preview_url` —
|
||||||
|
/// the same shape the server answers with when the file is absent,
|
||||||
|
/// so callers only handle one "no preview" case. Previews are
|
||||||
|
/// decorative; unlike archives they carry no checksum, only the
|
||||||
|
/// [`MAX_PREVIEW_BYTES`] size cap.
|
||||||
|
pub async fn fetch_preview(
|
||||||
|
&self,
|
||||||
|
entry: &ThemeCatalogEntry,
|
||||||
|
) -> Result<Vec<u8>, ThemeStoreError> {
|
||||||
|
let Some(path) = entry.preview_url.as_deref() else {
|
||||||
|
return Err(ThemeStoreError::Http(404));
|
||||||
|
};
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}{}", self.base_url, path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
if bytes.len() as u64 > MAX_PREVIEW_BYTES {
|
||||||
|
return Err(ThemeStoreError::Oversized {
|
||||||
|
expected: MAX_PREVIEW_BYTES,
|
||||||
|
got: bytes.len() as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Checks downloaded `bytes` against the catalog `entry`'s declared
|
||||||
|
/// size and SHA-256. Pure so it can be unit-tested without a server.
|
||||||
|
fn verify_archive(bytes: &[u8], entry: &ThemeCatalogEntry) -> Result<(), ThemeStoreError> {
|
||||||
|
if bytes.len() as u64 != entry.size_bytes {
|
||||||
|
return Err(ThemeStoreError::Oversized {
|
||||||
|
expected: entry.size_bytes,
|
||||||
|
got: bytes.len() as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let got = hex_digest(bytes);
|
||||||
|
// Case-insensitive: the server emits lowercase hex, but a
|
||||||
|
// hand-authored catalog mirror shouldn't fail on case alone.
|
||||||
|
if !got.eq_ignore_ascii_case(&entry.sha256) {
|
||||||
|
return Err(ThemeStoreError::ChecksumMismatch {
|
||||||
|
expected: entry.sha256.clone(),
|
||||||
|
got,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lowercase-hex SHA-256 of `bytes`. Mirrors the server's digest
|
||||||
|
/// encoding in `solitaire_server::theme_store`.
|
||||||
|
fn hex_digest(bytes: &[u8]) -> String {
|
||||||
|
let digest = Sha256::digest(bytes);
|
||||||
|
let mut out = String::with_capacity(digest.len() * 2);
|
||||||
|
for byte in digest {
|
||||||
|
out.push_str(&format!("{byte:02x}"));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn entry_for(bytes: &[u8]) -> ThemeCatalogEntry {
|
||||||
|
ThemeCatalogEntry {
|
||||||
|
id: "neon".into(),
|
||||||
|
name: "Neon".into(),
|
||||||
|
author: "Test".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
size_bytes: bytes.len() as u64,
|
||||||
|
sha256: hex_digest(bytes),
|
||||||
|
download_url: "/api/themes/neon/download".into(),
|
||||||
|
preview_url: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_accepts_matching_bytes() {
|
||||||
|
let bytes = b"theme archive bytes";
|
||||||
|
assert!(verify_archive(bytes, &entry_for(bytes)).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_accepts_uppercase_catalog_hash() {
|
||||||
|
let bytes = b"theme archive bytes";
|
||||||
|
let mut entry = entry_for(bytes);
|
||||||
|
entry.sha256 = entry.sha256.to_uppercase();
|
||||||
|
assert!(verify_archive(bytes, &entry).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_size_mismatch() {
|
||||||
|
let bytes = b"theme archive bytes";
|
||||||
|
let mut entry = entry_for(bytes);
|
||||||
|
entry.size_bytes += 1;
|
||||||
|
assert!(matches!(
|
||||||
|
verify_archive(bytes, &entry),
|
||||||
|
Err(ThemeStoreError::Oversized { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_rejects_checksum_mismatch() {
|
||||||
|
let bytes = b"theme archive bytes";
|
||||||
|
let mut entry = entry_for(bytes);
|
||||||
|
entry.sha256 = "0".repeat(64);
|
||||||
|
assert!(matches!(
|
||||||
|
verify_archive(bytes, &entry),
|
||||||
|
Err(ThemeStoreError::ChecksumMismatch { .. })
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! Local data export/import (Phase M — sync transparency).
|
||||||
|
//!
|
||||||
|
//! Bundles every player-owned JSON save (settings, stats, achievements,
|
||||||
|
//! progress) into one versioned backup file so a player can move their
|
||||||
|
//! data between devices without an account, or keep a manual backup.
|
||||||
|
//!
|
||||||
|
//! The bundle deliberately excludes the in-progress game and the replay
|
||||||
|
//! history: the former is transient, the latter can be large and is
|
||||||
|
//! shareable through the replay upload path instead.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use solitaire_sync::{AchievementRecord, PlayerProgress, StatsSnapshot};
|
||||||
|
|
||||||
|
/// Bundle format version. Bump on any breaking shape change; the loader
|
||||||
|
/// rejects newer versions rather than misreading them.
|
||||||
|
pub const DATA_BUNDLE_SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// File name of the default backup location (next to the other saves).
|
||||||
|
pub const DATA_BUNDLE_FILE_NAME: &str = "ferrous-solitaire-backup.json";
|
||||||
|
|
||||||
|
/// One-file backup of every player-owned JSON save.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DataBundle {
|
||||||
|
/// Format version — see [`DATA_BUNDLE_SCHEMA_VERSION`].
|
||||||
|
pub schema_version: u32,
|
||||||
|
/// When the bundle was written.
|
||||||
|
pub exported_at: DateTime<Utc>,
|
||||||
|
pub settings: Settings,
|
||||||
|
pub stats: StatsSnapshot,
|
||||||
|
pub achievements: Vec<AchievementRecord>,
|
||||||
|
pub progress: PlayerProgress,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a bundle could not be loaded.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BundleError {
|
||||||
|
#[error("could not read backup file: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
#[error("backup file is not valid JSON: {0}")]
|
||||||
|
Parse(#[from] serde_json::Error),
|
||||||
|
#[error("backup was written by a newer app version (schema {found} > {supported})")]
|
||||||
|
UnsupportedVersion { found: u32, supported: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default backup path: `<data dir>/<app dir>/ferrous-solitaire-backup.json`,
|
||||||
|
/// or `None` when no data directory is available on this platform.
|
||||||
|
pub fn data_bundle_path() -> Option<PathBuf> {
|
||||||
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(DATA_BUNDLE_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `bundle` to `path` atomically (`.tmp` → rename), creating parent
|
||||||
|
/// directories as needed. Mirrors the other save-file writers.
|
||||||
|
pub fn save_bundle_to(path: &Path, bundle: &DataBundle) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(bundle).map_err(io::Error::other)?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes())?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and validate a bundle from `path`. Unlike the per-file loaders this
|
||||||
|
/// does NOT default-on-error — an import silently replacing player data
|
||||||
|
/// with defaults would be destructive, so every failure is surfaced.
|
||||||
|
pub fn load_bundle_from(path: &Path) -> Result<DataBundle, BundleError> {
|
||||||
|
let data = fs::read(path)?;
|
||||||
|
let bundle: DataBundle = serde_json::from_slice(&data)?;
|
||||||
|
if bundle.schema_version > DATA_BUNDLE_SCHEMA_VERSION {
|
||||||
|
return Err(BundleError::UnsupportedVersion {
|
||||||
|
found: bundle.schema_version,
|
||||||
|
supported: DATA_BUNDLE_SCHEMA_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_bundle() -> DataBundle {
|
||||||
|
DataBundle {
|
||||||
|
schema_version: DATA_BUNDLE_SCHEMA_VERSION,
|
||||||
|
exported_at: Utc::now(),
|
||||||
|
settings: Settings::default(),
|
||||||
|
stats: StatsSnapshot {
|
||||||
|
games_played: 7,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_through_disk() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bundle_test_{}", std::process::id()));
|
||||||
|
let path = dir.join(DATA_BUNDLE_FILE_NAME);
|
||||||
|
let bundle = sample_bundle();
|
||||||
|
save_bundle_to(&path, &bundle).expect("save should succeed");
|
||||||
|
let loaded = load_bundle_from(&path).expect("load should succeed");
|
||||||
|
assert_eq!(loaded.schema_version, DATA_BUNDLE_SCHEMA_VERSION);
|
||||||
|
assert_eq!(loaded.stats.games_played, 7);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_newer_schema() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bundle_test_v_{}", std::process::id()));
|
||||||
|
let path = dir.join(DATA_BUNDLE_FILE_NAME);
|
||||||
|
let mut bundle = sample_bundle();
|
||||||
|
bundle.schema_version = DATA_BUNDLE_SCHEMA_VERSION + 1;
|
||||||
|
save_bundle_to(&path, &bundle).expect("save should succeed");
|
||||||
|
assert!(matches!(
|
||||||
|
load_bundle_from(&path),
|
||||||
|
Err(BundleError::UnsupportedVersion { .. })
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_is_an_io_error() {
|
||||||
|
assert!(matches!(
|
||||||
|
load_bundle_from(Path::new("/nonexistent/backup.json")),
|
||||||
|
Err(BundleError::Io(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! End-to-end theme-store test: a real `solitaire_server` router
|
||||||
|
//! serving a scanned catalog over a localhost TCP socket, consumed by
|
||||||
|
//! [`solitaire_data::ThemeStoreClient`].
|
||||||
|
//!
|
||||||
|
//! Mirrors the `sync_round_trip` harness: `TcpListener` on port 0,
|
||||||
|
//! router in a background `tokio::spawn`, no explicit shutdown.
|
||||||
|
|
||||||
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
|
use std::io::Write as _;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use solitaire_data::ThemeStoreClient;
|
||||||
|
use solitaire_server::theme_store::ThemeStore;
|
||||||
|
|
||||||
|
/// Minimal theme archive: the catalog scan only reads the `meta`
|
||||||
|
/// block, so no face SVGs are needed.
|
||||||
|
fn write_store_zip(dir: &Path, file_name: &str, id: &str, name: &str) -> PathBuf {
|
||||||
|
let manifest = format!(
|
||||||
|
r#"(
|
||||||
|
meta: (
|
||||||
|
id: "{id}",
|
||||||
|
name: "{name}",
|
||||||
|
author: "Round Trip",
|
||||||
|
version: "1.0.0",
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
),
|
||||||
|
faces: {{}},
|
||||||
|
back: "back.svg",
|
||||||
|
)"#
|
||||||
|
);
|
||||||
|
let path = dir.join(file_name);
|
||||||
|
let file = std::fs::File::create(&path).expect("create zip");
|
||||||
|
let mut writer = zip::ZipWriter::new(file);
|
||||||
|
let options = zip::write::SimpleFileOptions::default();
|
||||||
|
writer.start_file("theme.ron", options).expect("start_file");
|
||||||
|
writer
|
||||||
|
.write_all(manifest.as_bytes())
|
||||||
|
.expect("write manifest");
|
||||||
|
writer.finish().expect("finish zip");
|
||||||
|
path
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawn the test server with a theme store scanned from `store_dir`
|
||||||
|
/// and return its base URL.
|
||||||
|
async fn spawn_store_server(store_dir: &Path) -> String {
|
||||||
|
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||||
|
.await
|
||||||
|
.expect("failed to bind test listener");
|
||||||
|
let addr = listener.local_addr().expect("listener has no local addr");
|
||||||
|
|
||||||
|
let pool = solitaire_server::build_test_pool().await;
|
||||||
|
let app =
|
||||||
|
solitaire_server::build_test_router_with_theme_store(pool, ThemeStore::scan(store_dir));
|
||||||
|
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
eprintln!("test server crashed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
format!("http://{addr}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Catalog fetch → download → verified bytes match the file the
|
||||||
|
/// server scanned. This is the exact path the engine's store UI runs.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn catalog_fetch_and_verified_download_round_trip() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let zip_path = write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||||
|
let base_url = spawn_store_server(dir.path()).await;
|
||||||
|
|
||||||
|
let client = ThemeStoreClient::new(&base_url);
|
||||||
|
let catalog = client.fetch_catalog().await.expect("fetch catalog");
|
||||||
|
assert_eq!(catalog.len(), 1);
|
||||||
|
let entry = &catalog[0];
|
||||||
|
assert_eq!(entry.id, "neon");
|
||||||
|
|
||||||
|
let bytes = client.download_theme(entry).await.expect("download");
|
||||||
|
assert_eq!(bytes, std::fs::read(&zip_path).expect("read zip"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A catalog entry whose hash no longer matches the served file must
|
||||||
|
/// be rejected client-side — the importer never sees unverified bytes.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn download_with_stale_catalog_hash_is_rejected() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
write_store_zip(dir.path(), "neon.zip", "neon", "Neon");
|
||||||
|
let base_url = spawn_store_server(dir.path()).await;
|
||||||
|
|
||||||
|
let client = ThemeStoreClient::new(&base_url);
|
||||||
|
let mut entry = client.fetch_catalog().await.expect("fetch catalog")[0].clone();
|
||||||
|
entry.sha256 = "0".repeat(64);
|
||||||
|
|
||||||
|
let err = client
|
||||||
|
.download_theme(&entry)
|
||||||
|
.await
|
||||||
|
.expect_err("mismatched hash must fail");
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
err,
|
||||||
|
solitaire_data::ThemeStoreError::ChecksumMismatch { .. }
|
||||||
|
),
|
||||||
|
"expected ChecksumMismatch, got: {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An empty (or absent) store directory serves an empty catalog — the
|
||||||
|
/// client sees a store with nothing in it, not an error.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn empty_store_yields_empty_catalog() {
|
||||||
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
let base_url = spawn_store_server(dir.path()).await;
|
||||||
|
|
||||||
|
let client = ThemeStoreClient::new(&base_url);
|
||||||
|
let catalog = client.fetch_catalog().await.expect("fetch catalog");
|
||||||
|
assert!(catalog.is_empty());
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
@@ -71,6 +67,16 @@ pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
|||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct AchievementsStoragePath(pub Option<PathBuf>);
|
pub struct AchievementsStoragePath(pub Option<PathBuf>);
|
||||||
|
|
||||||
|
/// Set wrapping the win-path achievement evaluator. It writes
|
||||||
|
/// `AchievementsResource`/`ProgressResource` and emits unlock events; UI
|
||||||
|
/// readers of those (HUD popovers, the stats/achievements panels) order
|
||||||
|
/// themselves `.after(AchievementEval)`. The replay-completion evaluator is
|
||||||
|
/// deliberately NOT a member: it runs after `AutoComplete`, and panel chains
|
||||||
|
/// that need `.after(AchievementEval)` run before `AutoComplete` — putting
|
||||||
|
/// both in one set would close an ordering cycle (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct AchievementEval;
|
||||||
|
|
||||||
pub struct AchievementPlugin {
|
pub struct AchievementPlugin {
|
||||||
pub storage_path: Option<PathBuf>,
|
pub storage_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
@@ -123,9 +129,13 @@ impl Plugin for AchievementPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
evaluate_on_win
|
evaluate_on_win
|
||||||
|
.in_set(AchievementEval)
|
||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
.after(StatsUpdate)
|
.after(StatsUpdate)
|
||||||
.after(ProgressUpdate),
|
.after(ProgressUpdate)
|
||||||
|
// The unlock/XP/level-up events it emits are drained by
|
||||||
|
// the toast pipeline this frame (#143).
|
||||||
|
.before(crate::animation_plugin::AnimFx),
|
||||||
)
|
)
|
||||||
// Achievement-onboarding cue: fires once after the player's very
|
// Achievement-onboarding cue: fires once after the player's very
|
||||||
// first win to teach the Achievements panel exists. Must run
|
// first win to teach the Achievements panel exists. Must run
|
||||||
@@ -135,20 +145,50 @@ impl Plugin for AchievementPlugin {
|
|||||||
Update,
|
Update,
|
||||||
fire_achievement_onboarding_toast
|
fire_achievement_onboarding_toast
|
||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
.after(StatsUpdate),
|
.after(StatsUpdate)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters)
|
||||||
|
// Writes the one-shot onboarding flag into
|
||||||
|
// SettingsResource: board painters re-read settings, so
|
||||||
|
// land the write first; UiTextFx members read disjoint
|
||||||
|
// settings fields (reduce-motion), so writer/reader
|
||||||
|
// order there is provably irrelevant (#143).
|
||||||
|
.before(crate::card_plugin::BoardVisuals)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.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(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>,
|
scroll_achievements_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
)
|
)
|
||||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||||
// `cinephile` the first time playback runs to natural completion.
|
// `cinephile` the first time playback runs to natural completion.
|
||||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
// Reads the resource via `Option<Res<_>>` so headless tests that
|
||||||
// omit `ReplayPlaybackPlugin` still build.
|
// omit `ReplayPlaybackPlugin` still build.
|
||||||
.add_systems(Update, evaluate_cinephile_on_replay_completion);
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
// Shares the unlock stream and AchievementsResource with
|
||||||
|
// evaluate_on_win; ordered after it and before the toast
|
||||||
|
// pipeline that drains the unlock events (#143).
|
||||||
|
evaluate_cinephile_on_replay_completion
|
||||||
|
.after(evaluate_on_win)
|
||||||
|
// The stats overlay's watch-replay button (ordered
|
||||||
|
// before AutoComplete) writes ReplayPlaybackState this
|
||||||
|
// system reads.
|
||||||
|
.after(crate::auto_complete_plugin::AutoComplete)
|
||||||
|
.before(crate::animation_plugin::AnimFx),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,47 +425,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 +457,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 +489,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 +602,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 +662,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>>();
|
||||||
@@ -911,8 +906,7 @@ mod tests {
|
|||||||
|
|
||||||
// Put the active game in Zen mode. evaluate_on_win reads
|
// Put the active game in Zen mode. evaluate_on_win reads
|
||||||
// GameStateResource.mode directly to populate last_win_is_zen.
|
// GameStateResource.mode directly to populate last_win_is_zen.
|
||||||
app.world_mut().resource_mut::<GameStateResource>().0.mode =
|
app.world_mut().resource_mut::<GameStateResource>().0.mode = GameMode::Zen;
|
||||||
GameMode::Zen;
|
|
||||||
|
|
||||||
app.world_mut().write_message(GameWonEvent {
|
app.world_mut().write_message(GameWonEvent {
|
||||||
score: 0,
|
score: 0,
|
||||||
@@ -1393,7 +1387,9 @@ mod tests {
|
|||||||
|
|
||||||
use crate::replay_playback::ReplayPlaybackState;
|
use crate::replay_playback::ReplayPlaybackState;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{DrawStockConfig, KlondikeInstruction, game_state::GameMode};
|
use solitaire_core::{
|
||||||
|
DrawStockConfig, KlondikeInstruction, SessionRecording, game_state::GameMode,
|
||||||
|
};
|
||||||
use solitaire_data::Replay;
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
/// Headless app variant that injects a default `ReplayPlaybackState`
|
/// Headless app variant that injects a default `ReplayPlaybackState`
|
||||||
@@ -1414,7 +1410,11 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![KlondikeInstruction::RotateStock],
|
SessionRecording::from_instructions_unchecked(
|
||||||
|
1,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
[KlondikeInstruction::RotateStock],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1458,12 +1458,8 @@ mod tests {
|
|||||||
|
|
||||||
// Frame 1: enter Playing. The observer's first sample sees
|
// Frame 1: enter Playing. The observer's first sample sees
|
||||||
// `last_was_playing = false` and `now_playing = true`.
|
// `last_was_playing = false` and `now_playing = true`.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
assert!(
|
assert!(
|
||||||
!cinephile_unlocked(&app),
|
!cinephile_unlocked(&app),
|
||||||
@@ -1492,12 +1488,8 @@ mod tests {
|
|||||||
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
fn cinephile_does_not_unlock_on_stop_button_abort() {
|
||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
// Direct Playing → Inactive — the path the Stop button takes via
|
// Direct Playing → Inactive — the path the Stop button takes via
|
||||||
@@ -1523,12 +1515,8 @@ mod tests {
|
|||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
// First completion cycle to unlock.
|
// First completion cycle to unlock.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1546,12 +1534,8 @@ mod tests {
|
|||||||
// Second cycle: Inactive → Playing → Completed once more.
|
// Second cycle: Inactive → Playing → Completed once more.
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Inactive;
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
@@ -1570,12 +1554,8 @@ mod tests {
|
|||||||
fn cinephile_fires_once_across_completed_linger() {
|
fn cinephile_fires_once_across_completed_linger() {
|
||||||
let mut app = cinephile_app();
|
let mut app = cinephile_app();
|
||||||
|
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Playing {
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() =
|
||||||
replay: dummy_replay(),
|
ReplayPlaybackState::playing(dummy_replay(), 0, 0.0, false);
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
app.update();
|
app.update();
|
||||||
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
*app.world_mut().resource_mut::<ReplayPlaybackState>() = ReplayPlaybackState::Completed;
|
||||||
app.update();
|
app.update();
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ use crate::events::{
|
|||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
|
use crate::safe_area::SafeAreaAnchoredBottom;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
@@ -160,9 +162,82 @@ pub struct ActiveToast {
|
|||||||
/// Duration of each queued info-toast in seconds.
|
/// Duration of each queued info-toast in seconds.
|
||||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||||
|
|
||||||
|
/// Marker on the persistent bottom-anchored flex column every toast
|
||||||
|
/// spawns into (Phase H). Stacking through one container gives queued
|
||||||
|
/// and immediate toasts a single shared anchor — simultaneous toasts
|
||||||
|
/// stack upward instead of relying on the old staggered-percentage
|
||||||
|
/// anchors to dodge each other.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastStackRoot;
|
||||||
|
|
||||||
|
/// Marker on every toast card node (both paths). Freshly spawned toasts
|
||||||
|
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
|
||||||
|
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
|
||||||
|
/// of latency, imperceptible at toast timescales, in exchange for the 14
|
||||||
|
/// toast handlers keeping their `Commands`-only signatures.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastNode;
|
||||||
|
|
||||||
|
/// Logical-pixel gap between the screen bottom and the toast stack,
|
||||||
|
/// before safe-area insets. Clears the floating glass tab bar on touch
|
||||||
|
/// (its margin + height + breathing room); desktop's docked bar needs less.
|
||||||
|
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT {
|
||||||
|
crate::hud_plugin::tab_bar::TAB_BAR_CLEARANCE_PX
|
||||||
|
} else {
|
||||||
|
72.0
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
||||||
|
fn spawn_toast_stack_root(mut commands: Commands) {
|
||||||
|
commands.spawn((
|
||||||
|
ToastStackRoot,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
// Newest toast sits nearest the bottom edge; older ones
|
||||||
|
// push upward.
|
||||||
|
flex_direction: FlexDirection::ColumnReverse,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
SafeAreaAnchoredBottom {
|
||||||
|
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
|
||||||
|
},
|
||||||
|
ZIndex(Z_TOAST),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
|
||||||
|
/// reveals them. No-op when every toast is already adopted.
|
||||||
|
fn adopt_toasts_into_stack(
|
||||||
|
mut commands: Commands,
|
||||||
|
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
|
||||||
|
root: Query<Entity, With<ToastStackRoot>>,
|
||||||
|
) {
|
||||||
|
let Ok(root) = root.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for toast in &orphans {
|
||||||
|
commands
|
||||||
|
.entity(toast)
|
||||||
|
.insert((ChildOf(root), Visibility::Inherited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||||
pub struct AnimationPlugin;
|
pub struct AnimationPlugin;
|
||||||
|
|
||||||
|
/// Set wrapping this plugin's Update chain (card slide/cascade animation and
|
||||||
|
/// the toast pipeline). Ordered after [`crate::card_animation::CardTweenFx`]
|
||||||
|
/// on the shared visual spine — members write card `Transform`s, which every
|
||||||
|
/// other painter set also touches — and after the toast/stat/progress writer
|
||||||
|
/// sets so the toast handlers drain this frame's events (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct AnimFx;
|
||||||
|
|
||||||
impl Plugin for AnimationPlugin {
|
impl Plugin for AnimationPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register the events this plugin consumes so tests that don't include
|
// Register the events this plugin consumes so tests that don't include
|
||||||
@@ -185,13 +260,37 @@ impl Plugin for AnimationPlugin {
|
|||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
.add_systems(Startup, init_slide_duration)
|
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
AnimFx
|
||||||
|
// Visual spine: …BoardVisuals < MarkerVisuals <
|
||||||
|
// CardTweenFx < AnimFx < FeedbackFx (#143). GameMutation
|
||||||
|
// ordering is inherited transitively.
|
||||||
|
.after(crate::card_animation::CardTweenFx)
|
||||||
|
.after(crate::table_plugin::MarkerVisuals)
|
||||||
|
// Stats/progress must settle before the win/level-up
|
||||||
|
// toast handlers read their events. (No edge against
|
||||||
|
// InfoToastWriters: toast writers exist on both sides
|
||||||
|
// of this set — enqueue_toasts drains via cursor and
|
||||||
|
// joins the club instead; a toast surfacing one frame
|
||||||
|
// later is invisible.)
|
||||||
|
.after(crate::stats_plugin::StatsUpdate)
|
||||||
|
.after(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.after(GameMutation)
|
||||||
|
// HUD text pulses are disjoint from the card entities
|
||||||
|
// this set animates (see UiTextFx × BoardVisuals, #143).
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
advance_card_anims,
|
advance_card_anims,
|
||||||
sync_slide_duration,
|
// Both drain SettingsChangedEvent / read anim-speed
|
||||||
handle_win_cascade,
|
// fields via cursor; a home deal-option edit surfacing
|
||||||
|
// one frame later is invisible (#143).
|
||||||
|
sync_slide_duration.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
handle_win_cascade.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
handle_achievement_toast,
|
handle_achievement_toast,
|
||||||
handle_levelup_toast,
|
handle_levelup_toast,
|
||||||
handle_daily_goal_announcement_toast,
|
handle_daily_goal_announcement_toast,
|
||||||
@@ -199,15 +298,23 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_weekly_toast,
|
handle_weekly_toast,
|
||||||
handle_time_attack_toast,
|
handle_time_attack_toast,
|
||||||
handle_challenge_toast,
|
handle_challenge_toast,
|
||||||
handle_settings_toast,
|
handle_settings_toast.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
handle_auto_complete_toast,
|
handle_auto_complete_toast,
|
||||||
handle_xp_awarded_toast,
|
handle_xp_awarded_toast,
|
||||||
handle_move_rejected_toast,
|
handle_move_rejected_toast,
|
||||||
handle_warning_toast,
|
handle_warning_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(
|
||||||
|
enqueue_toasts
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
drive_toast_display,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
adopt_toasts_into_stack,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.chain()
|
||||||
|
.in_set(AnimFx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,10 +359,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 +672,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 +714,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
|
||||||
@@ -623,26 +744,14 @@ impl ToastVariant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
|
/// Spawns a `ToastEntity` for the queued toast system.
|
||||||
///
|
///
|
||||||
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||||
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||||
/// Variants other than `Info` belong on the immediate-fire path
|
/// Variants other than `Info` belong on the immediate-fire path
|
||||||
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||||
spawn_toast_node(
|
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
|
||||||
commands,
|
|
||||||
ToastEntity,
|
|
||||||
message,
|
|
||||||
ToastVariant::Info,
|
|
||||||
// Slightly taller anchor than the immediate-fire path so a
|
|
||||||
// queued info banner doesn't collide with a celebration toast
|
|
||||||
// fired in the same frame.
|
|
||||||
Val::Percent(6.0),
|
|
||||||
Val::Percent(15.0),
|
|
||||||
Val::Percent(70.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||||
@@ -730,12 +839,6 @@ fn spawn_toast(
|
|||||||
(ToastOverlay, ToastTimer(duration_secs)),
|
(ToastOverlay, ToastTimer(duration_secs)),
|
||||||
message,
|
message,
|
||||||
variant,
|
variant,
|
||||||
// Sits above the queued banner so a celebration toast spawned
|
|
||||||
// alongside a queued info message remains readable.
|
|
||||||
Val::Percent(14.0),
|
|
||||||
Val::Percent(25.0),
|
|
||||||
Val::Percent(50.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,31 +855,25 @@ fn spawn_toast(
|
|||||||
/// rungs; 18 is the closest rung that preserves the scale invariants
|
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||||
/// tested in `ui_theme::tests`.
|
/// tested in `ui_theme::tests`.
|
||||||
/// - [`RADIUS_MD`] corners.
|
/// - [`RADIUS_MD`] corners.
|
||||||
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
///
|
||||||
/// queued and immediate paths so they layer instead of overlap.
|
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
|
||||||
// The 8-argument signature is intentional — these are the per-toast
|
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
|
||||||
// layout values that genuinely differ between the queued and fire-and-
|
/// into the shared bottom-anchored column — one anchor for every toast,
|
||||||
// forget call sites. A struct wrapper would just rename the same data.
|
/// simultaneous toasts stack instead of overlapping.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn spawn_toast_node<B: Bundle>(
|
fn spawn_toast_node<B: Bundle>(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
bundle: B,
|
bundle: B,
|
||||||
message: String,
|
message: String,
|
||||||
variant: ToastVariant,
|
variant: ToastVariant,
|
||||||
bottom_pct: Val,
|
|
||||||
left_pct: Val,
|
|
||||||
width_pct: Val,
|
|
||||||
padding: UiRect,
|
|
||||||
) -> Entity {
|
) -> Entity {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
bundle,
|
bundle,
|
||||||
|
ToastNode,
|
||||||
|
Visibility::Hidden,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
max_width: Val::Percent(70.0),
|
||||||
left: left_pct,
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||||
bottom: bottom_pct,
|
|
||||||
width: width_pct,
|
|
||||||
padding,
|
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
@@ -785,7 +882,6 @@ fn spawn_toast_node<B: Bundle>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
BorderColor::all(variant.border_color()),
|
BorderColor::all(variant.border_color()),
|
||||||
ZIndex(Z_TOAST),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -1301,4 +1397,85 @@ mod tests {
|
|||||||
fn cascade_duration_instant_is_zero() {
|
fn cascade_duration_instant_is_zero() {
|
||||||
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H: unified toast stack
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Both toast paths must end up as visible children of the single
|
||||||
|
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
|
||||||
|
#[test]
|
||||||
|
fn queued_and_immediate_toasts_stack_under_one_root() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update(); // Startup: spawns the stack root.
|
||||||
|
|
||||||
|
// One immediate (error) toast + one queued (info) toast in the
|
||||||
|
// same frame — the exact collision case the old staggered
|
||||||
|
// anchors existed to dodge.
|
||||||
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
|
app.world_mut().write_message(MoveRejectedEvent {
|
||||||
|
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(InfoToastEvent("stacked info".to_string()));
|
||||||
|
app.update(); // handlers spawn both toasts (hidden, unparented)
|
||||||
|
app.update(); // adopt_toasts_into_stack re-parents + reveals
|
||||||
|
|
||||||
|
let root = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<ToastStackRoot>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("exactly one ToastStackRoot must exist");
|
||||||
|
|
||||||
|
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
toasts.len(),
|
||||||
|
2,
|
||||||
|
"both the immediate and the queued toast must be adopted"
|
||||||
|
);
|
||||||
|
for (entity, child_of, visibility) in toasts {
|
||||||
|
assert_eq!(
|
||||||
|
child_of.parent(),
|
||||||
|
root,
|
||||||
|
"toast {entity} must be a child of the shared stack root"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*visibility,
|
||||||
|
Visibility::Inherited,
|
||||||
|
"adopted toast {entity} must be revealed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toast nodes must not carry their own absolute positioning — the
|
||||||
|
/// stack root owns layout (regression guard against reintroducing
|
||||||
|
/// per-path anchors).
|
||||||
|
#[test]
|
||||||
|
fn toast_nodes_have_no_absolute_position() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(WarningToastEvent("layout check".to_string()));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let node = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Node, With<ToastNode>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("warning toast must spawn a ToastNode");
|
||||||
|
assert_eq!(
|
||||||
|
node.position_type,
|
||||||
|
PositionType::Relative,
|
||||||
|
"toast nodes are flex children of the stack, not absolute overlays"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,9 +74,11 @@ pub const ALL_RANKS: [Rank; 13] = [
|
|||||||
Rank::King,
|
Rank::King,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Every suit in `Clubs, Diamonds, Hearts, Spades` order — matches
|
/// Iteration order for the SVG generator and the pin test only —
|
||||||
/// `card_plugin::load_card_images` so the suit index used here lines
|
/// output files are keyed by `suit_filename`, so no runtime index
|
||||||
/// up with `CardImageSet.faces[suit]`.
|
/// depends on this order. Kept local (not `Suit::SUITS`) because
|
||||||
|
/// reordering would churn the pinned snapshot ordering for no
|
||||||
|
/// benefit.
|
||||||
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
pub const ALL_SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||||
|
|
||||||
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
|
/// The rank component of the on-disk filename — `A`, `2`..`10`, `J`,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! SVG builders for the floating tab bar's action icons.
|
||||||
|
//!
|
||||||
|
//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
|
||||||
|
//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
|
||||||
|
//! is missing on Android entirely), so the icons are authored here as tiny
|
||||||
|
//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
|
||||||
|
//! as the card themes (`assets::rasterize_svg`).
|
||||||
|
//!
|
||||||
|
//! Icons are drawn in **white** on a transparent background inside a
|
||||||
|
//! 24-unit logical box; the spawn site tints them to the theme colour via
|
||||||
|
//! `ImageNode::color` (a per-node multiply), so one texture serves every
|
||||||
|
//! button state.
|
||||||
|
|
||||||
|
use bevy::image::Image;
|
||||||
|
use bevy::math::UVec2;
|
||||||
|
|
||||||
|
use super::svg_loader::{SvgLoaderError, rasterize_svg};
|
||||||
|
|
||||||
|
/// Rasterisation size for one tab-bar icon texture. Icons display at
|
||||||
|
/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
|
||||||
|
/// factor (and any future 3× phone) while staying trivially small.
|
||||||
|
pub const HUD_ICON_TEXTURE_PX: u32 = 96;
|
||||||
|
|
||||||
|
/// The five actions the floating touch tab bar can show.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HudIcon {
|
||||||
|
/// Counter-clockwise "take back" arrow.
|
||||||
|
Undo,
|
||||||
|
/// Two offset card outlines — draw from the stock.
|
||||||
|
Draw,
|
||||||
|
/// Lightbulb — highlight a suggested move.
|
||||||
|
Hint,
|
||||||
|
/// Two vertical bars.
|
||||||
|
Pause,
|
||||||
|
/// Three-line hamburger — the floating menu button.
|
||||||
|
Menu,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HudIcon {
|
||||||
|
/// Build the icon's SVG document. All icons share one visual language:
|
||||||
|
/// 2-unit white strokes, round caps, transparent fill, 24-unit box.
|
||||||
|
pub fn svg(self) -> String {
|
||||||
|
let body = match self {
|
||||||
|
// Arrow head pointing left into a half-circle sweeping back
|
||||||
|
// over the top — the conventional undo mark.
|
||||||
|
HudIcon::Undo => concat!(
|
||||||
|
r##"<path d="M 8 5 L 4 9 L 8 13"/>"##,
|
||||||
|
r##"<path d="M 4 9 H 14 A 5.5 5.5 0 0 1 14 20 H 8"/>"##,
|
||||||
|
),
|
||||||
|
// Back card peeks out behind the front card; the front card is
|
||||||
|
// drawn second so its stroke reads on top.
|
||||||
|
HudIcon::Draw => concat!(
|
||||||
|
r##"<rect x="8.5" y="3" width="11" height="15" rx="2"/>"##,
|
||||||
|
r##"<rect x="4" y="6.5" width="11" height="15" rx="2"/>"##,
|
||||||
|
),
|
||||||
|
// Bulb dome with a flat lamp base beneath.
|
||||||
|
HudIcon::Hint => concat!(
|
||||||
|
r##"<path d="M 9 15.5 A 6.5 6.5 0 1 1 15 15.5 V 18 H 9 Z"/>"##,
|
||||||
|
r##"<path d="M 10 21 H 14"/>"##,
|
||||||
|
),
|
||||||
|
HudIcon::Pause => concat!(
|
||||||
|
r##"<path d="M 9 6 V 18"/>"##,
|
||||||
|
r##"<path d="M 15 6 V 18"/>"##,
|
||||||
|
),
|
||||||
|
HudIcon::Menu => concat!(
|
||||||
|
r##"<path d="M 4 6.5 H 20"/>"##,
|
||||||
|
r##"<path d="M 4 12 H 20"/>"##,
|
||||||
|
r##"<path d="M 4 17.5 H 20"/>"##,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
concat!(
|
||||||
|
r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">"##,
|
||||||
|
r##"<g fill="none" stroke="#ffffff" stroke-width="2" "##,
|
||||||
|
r##"stroke-linecap="round" stroke-linejoin="round">{body}</g>"##,
|
||||||
|
r##"</svg>"##,
|
||||||
|
),
|
||||||
|
body = body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterise this icon into a Bevy [`Image`] at
|
||||||
|
/// [`HUD_ICON_TEXTURE_PX`] square.
|
||||||
|
pub fn rasterize(self) -> Result<Image, SvgLoaderError> {
|
||||||
|
rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ALL: [HudIcon; 5] = [
|
||||||
|
HudIcon::Undo,
|
||||||
|
HudIcon::Draw,
|
||||||
|
HudIcon::Hint,
|
||||||
|
HudIcon::Pause,
|
||||||
|
HudIcon::Menu,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every icon must survive the real usvg/resvg pipeline at the real
|
||||||
|
/// texture size — a malformed path here would otherwise only surface
|
||||||
|
/// as a blank button on device.
|
||||||
|
#[test]
|
||||||
|
fn every_icon_rasterizes_at_texture_size() {
|
||||||
|
for icon in ALL {
|
||||||
|
let img = icon.rasterize().expect("icon SVG must rasterise");
|
||||||
|
assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
|
||||||
|
assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterised icons must contain visible (non-transparent) pixels —
|
||||||
|
/// guards against a stroke accidentally drawn outside the viewBox.
|
||||||
|
#[test]
|
||||||
|
fn every_icon_has_visible_pixels() {
|
||||||
|
for icon in ALL {
|
||||||
|
let img = icon.rasterize().expect("icon SVG must rasterise");
|
||||||
|
let data = img.data.as_ref().expect("rasterised image has data");
|
||||||
|
let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
|
||||||
|
assert!(
|
||||||
|
opaque > 100,
|
||||||
|
"{icon:?} renders only {opaque} visible pixels"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! custom `AssetSource` implementations used by the theme system.
|
//! custom `AssetSource` implementations used by the theme system.
|
||||||
|
|
||||||
pub mod card_face_svg;
|
pub mod card_face_svg;
|
||||||
|
pub mod hud_icon_svg;
|
||||||
pub mod icon_svg;
|
pub mod icon_svg;
|
||||||
pub mod sources;
|
pub mod sources;
|
||||||
pub mod svg_loader;
|
pub mod svg_loader;
|
||||||
|
|||||||
@@ -53,12 +53,12 @@ pub fn set_user_theme_dir(path: PathBuf) -> Result<(), PathBuf> {
|
|||||||
/// Returns the absolute path of the user-themes directory on the
|
/// Returns the absolute path of the user-themes directory on the
|
||||||
/// current platform.
|
/// current platform.
|
||||||
///
|
///
|
||||||
/// # Panics
|
/// When [`solitaire_data::data_dir`] returns `None` (broken `$HOME` /
|
||||||
///
|
/// `$XDG_*` on desktop; always on wasm32, which has no filesystem) this
|
||||||
/// Panics if [`solitaire_data::data_dir`] returns `None`, which on
|
/// returns an empty path — callers treat that as "no user themes" and
|
||||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration.
|
/// the bundled default theme still works. A warning naming the
|
||||||
/// Android always returns `Some`. The panic message names the
|
/// [`set_user_theme_dir`] workaround is logged once. Android always
|
||||||
/// supported workaround ([`set_user_theme_dir`]).
|
/// resolves.
|
||||||
pub fn user_theme_dir() -> PathBuf {
|
pub fn user_theme_dir() -> PathBuf {
|
||||||
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
if let Some(p) = USER_THEME_DIR_OVERRIDE.get() {
|
||||||
return p.clone();
|
return p.clone();
|
||||||
@@ -76,29 +76,32 @@ fn user_theme_dir_for(data_dir: PathBuf) -> PathBuf {
|
|||||||
/// Per-target-os resolution of the platform's data dir. Delegates
|
/// Per-target-os resolution of the platform's data dir. Delegates
|
||||||
/// to [`solitaire_data::data_dir`] which encapsulates the
|
/// to [`solitaire_data::data_dir`] which encapsulates the
|
||||||
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
/// per-target shape (desktop: `dirs::data_dir()`; android: the
|
||||||
/// hardcoded `/data/data/<package>/files` sandbox path). Panics
|
/// hardcoded `/data/data/<package>/files` sandbox path).
|
||||||
/// only when the underlying resolver returns `None`, which on
|
///
|
||||||
/// desktop indicates a broken `$HOME` / `$XDG_*` configuration —
|
/// When the resolver returns `None` — always on wasm32 (no
|
||||||
/// the panic message names the supported workaround.
|
/// filesystem), or a broken `$HOME` / `$XDG_*` configuration on
|
||||||
|
/// desktop — this degrades to an empty path, which downstream theme
|
||||||
|
/// scanning treats as "no user themes"; the bundled default theme is
|
||||||
|
/// unaffected. CLAUDE.md §2.3 forbids panicking here: losing custom
|
||||||
|
/// themes must not take the whole game down with it.
|
||||||
fn detected_platform_data_dir() -> PathBuf {
|
fn detected_platform_data_dir() -> PathBuf {
|
||||||
solitaire_data::data_dir().unwrap_or_else(|| {
|
solitaire_data::data_dir().unwrap_or_else(|| {
|
||||||
// On wasm32, data_dir() always returns None — there is no filesystem.
|
|
||||||
// User themes are not supported in the browser build; return an empty
|
|
||||||
// path so callers produce a benign empty dir rather than panicking.
|
|
||||||
#[cfg(target_arch = "wasm32")]
|
|
||||||
{
|
|
||||||
PathBuf::new()
|
|
||||||
}
|
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
{
|
{
|
||||||
panic!(
|
use std::sync::Once;
|
||||||
"user_theme_dir(): platform data directory is unavailable. \
|
static WARN_ONCE: Once = Once::new();
|
||||||
On Linux check $XDG_DATA_HOME or $HOME; on macOS / Windows \
|
WARN_ONCE.call_once(|| {
|
||||||
the OS reported no Application Support / AppData path. \
|
bevy::log::warn!(
|
||||||
As a workaround call solitaire_engine::assets::user_dir::\
|
"user_theme_dir(): platform data directory is unavailable; \
|
||||||
set_user_theme_dir() before App::run()."
|
user themes are disabled. On Linux check $XDG_DATA_HOME or \
|
||||||
)
|
$HOME; on macOS / Windows the OS reported no Application \
|
||||||
|
Support / AppData path. As a workaround call \
|
||||||
|
solitaire_engine::assets::user_dir::set_user_theme_dir() \
|
||||||
|
before App::run()."
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
PathBuf::new()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ pub struct AutoCompleteState {
|
|||||||
/// Plugin that drives the auto-complete sequence.
|
/// Plugin that drives the auto-complete sequence.
|
||||||
pub struct AutoCompletePlugin;
|
pub struct AutoCompletePlugin;
|
||||||
|
|
||||||
|
/// Set wrapping the auto-complete detect/drive chain; HUD readers of
|
||||||
|
/// [`AutoCompleteState`] order themselves after it (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct AutoComplete;
|
||||||
|
|
||||||
impl Plugin for AutoCompletePlugin {
|
impl Plugin for AutoCompletePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<AutoCompleteState>()
|
app.init_resource::<AutoCompleteState>()
|
||||||
@@ -58,7 +63,9 @@ impl Plugin for AutoCompletePlugin {
|
|||||||
drive_auto_complete,
|
drive_auto_complete,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.in_set(AutoComplete)
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -140,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;
|
||||||
@@ -147,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 {
|
||||||
@@ -167,9 +179,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{Deck, Rank, Suit};
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
fn headless_app() -> App {
|
fn headless_app() -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
@@ -207,7 +219,11 @@ mod tests {
|
|||||||
}
|
}
|
||||||
g.set_test_tableau_cards(
|
g.set_test_tableau_cards(
|
||||||
Tableau::Tableau1,
|
Tableau::Tableau1,
|
||||||
vec![solitaire_core::Card::new(Deck::Deck1, Suit::Clubs, Rank::Ace)],
|
vec![solitaire_core::Card::new(
|
||||||
|
Deck::Deck1,
|
||||||
|
Suit::Clubs,
|
||||||
|
Rank::Ace,
|
||||||
|
)],
|
||||||
);
|
);
|
||||||
g.set_test_auto_completable(true);
|
g.set_test_auto_completable(true);
|
||||||
let expected = (
|
let expected = (
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -283,27 +201,6 @@ pub(crate) fn advance_card_animations(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Win cascade
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Win-cascade scatter targets — 8 points beyond the window edges.
|
|
||||||
///
|
|
||||||
/// Scaled by `radius` (pass `layout.card_size.x * 8.0` for a good result).
|
|
||||||
pub fn win_scatter_targets(radius: f32) -> [Vec2; 8] {
|
|
||||||
let r = radius;
|
|
||||||
[
|
|
||||||
Vec2::new(r, r),
|
|
||||||
Vec2::new(-r, r),
|
|
||||||
Vec2::new(r, -r),
|
|
||||||
Vec2::new(-r, -r),
|
|
||||||
Vec2::new(0.0, r),
|
|
||||||
Vec2::new(0.0, -r),
|
|
||||||
Vec2::new(r, 0.0),
|
|
||||||
Vec2::new(-r, 0.0),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Tests
|
// Tests
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -386,21 +283,4 @@ mod tests {
|
|||||||
.with_z_lift(12.0);
|
.with_z_lift(12.0);
|
||||||
assert!((anim.z_lift - 12.0).abs() < 1e-6);
|
assert!((anim.z_lift - 12.0).abs() < 1e-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn win_scatter_has_eight_targets() {
|
|
||||||
let targets = win_scatter_targets(800.0);
|
|
||||||
assert_eq!(targets.len(), 8);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn win_scatter_targets_are_off_center() {
|
|
||||||
for t in win_scatter_targets(400.0) {
|
|
||||||
let dist = t.length();
|
|
||||||
assert!(
|
|
||||||
dist > 100.0,
|
|
||||||
"scatter target should be well off-center: {t:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,9 +72,7 @@ pub struct HoverState {
|
|||||||
/// Describes a user action that arrived while cards were still animating.
|
/// Describes a user action that arrived while cards were still animating.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum BufferedInput {
|
pub enum BufferedInput {
|
||||||
Move {
|
Move { from: MoveRequestEvent },
|
||||||
from: MoveRequestEvent,
|
|
||||||
},
|
|
||||||
Draw,
|
Draw,
|
||||||
Undo,
|
Undo,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,38 +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
|
|
||||||
//!
|
|
||||||
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
|
|
||||||
//! (linear). To use the curve-based cascade instead, disable
|
|
||||||
//! `handle_win_cascade` in `AnimationPlugin` and register `WinCascadePlugin`
|
|
||||||
//! (declared below) which uses `CardAnimation` + `MotionCurve::Expressive`.
|
|
||||||
//!
|
|
||||||
//! They **must not both be active** — both write to `Transform` on the same
|
|
||||||
//! 52 entities and will race.
|
|
||||||
//!
|
|
||||||
//! # Coexistence rules
|
//! # Coexistence rules
|
||||||
//!
|
//!
|
||||||
//! | Condition | Safe? |
|
//! | Condition | Safe? |
|
||||||
@@ -80,24 +48,21 @@ 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;
|
||||||
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};
|
||||||
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
pub use interaction::{BufferedInput, HoverState, InputBuffer};
|
||||||
pub use timing::{
|
pub use timing::{
|
||||||
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, WIN_CASCADE_INTERVAL_SECS,
|
DEAL_INTERVAL_SECS, MAX_DURATION_SECS, MIN_DURATION_SECS, compute_duration, micro_vary,
|
||||||
cascade_delay, compute_duration, micro_vary,
|
|
||||||
};
|
};
|
||||||
pub use tuning::{AnimationTuning, InputPlatform};
|
pub use tuning::{AnimationTuning, InputPlatform};
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::RequestRedraw;
|
use bevy::window::RequestRedraw;
|
||||||
|
|
||||||
use crate::card_plugin::CardEntity;
|
|
||||||
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
use crate::events::{DrawRequestEvent, GameWonEvent, MoveRequestEvent, UndoRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
|
||||||
use crate::resources::DragState;
|
use crate::resources::DragState;
|
||||||
|
|
||||||
use animation::advance_card_animations;
|
use animation::advance_card_animations;
|
||||||
@@ -118,6 +83,14 @@ use tuning::update_input_platform;
|
|||||||
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
|
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
|
||||||
pub struct CardAnimationPlugin;
|
pub struct CardAnimationPlugin;
|
||||||
|
|
||||||
|
/// Set wrapping this plugin's Update chain (curve tweens, hover/drag
|
||||||
|
/// visuals). First anim stage on the visual spine: …BoardVisuals <
|
||||||
|
/// MarkerVisuals < CardTweenFx < [`crate::animation_plugin::AnimFx`] <
|
||||||
|
/// FeedbackFx — members write card `Transform`s, which every other painter
|
||||||
|
/// set also touches (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct CardTweenFx;
|
||||||
|
|
||||||
impl Plugin for CardAnimationPlugin {
|
impl Plugin for CardAnimationPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register events and resources idempotently — double-registration is
|
// Register events and resources idempotently — double-registration is
|
||||||
@@ -150,7 +123,14 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
// still present in the same frame it was removed (deferred
|
// still present in the same frame it was removed (deferred
|
||||||
// commands aren't applied until the next ApplyDeferred
|
// commands aren't applied until the next ApplyDeferred
|
||||||
// point), causing a 1-frame gap between every chain step.
|
// point), causing a 1-frame gap between every chain step.
|
||||||
ApplyDeferred,
|
//
|
||||||
|
// `ambiguous_with_all`: an explicit sync point conflicts
|
||||||
|
// (on `World`) with every unordered system in the
|
||||||
|
// schedule. Only the flush of THIS chain's commands needs
|
||||||
|
// ordering — the chain edges provide that; whether an
|
||||||
|
// unrelated system's commands flush here or at the next
|
||||||
|
// sync point is immaterial to it (#143).
|
||||||
|
ApplyDeferred.ambiguous_with_all(),
|
||||||
// After each animation finishes, pop the next chain segment.
|
// After each animation finishes, pop the next chain segment.
|
||||||
advance_animation_chains,
|
advance_animation_chains,
|
||||||
// Interaction visuals (run after animation for final positions).
|
// Interaction visuals (run after animation for final positions).
|
||||||
@@ -158,68 +138,25 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
apply_hover_scale,
|
apply_hover_scale,
|
||||||
apply_drag_visual,
|
apply_drag_visual,
|
||||||
// Drain buffered inputs only when no animations remain.
|
// Drain buffered inputs only when no animations remain.
|
||||||
drain_input_buffer,
|
// It re-emits buffered undo/new-game requests, so it
|
||||||
|
// joins the self-ambiguous writer clubs (#143).
|
||||||
|
drain_input_buffer
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.in_set(CardTweenFx),
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Optional: win cascade with Expressive curve
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Optional plugin that replaces the linear win cascade in `AnimationPlugin`
|
|
||||||
/// with an `Expressive`-curve cascade.
|
|
||||||
///
|
|
||||||
/// **Do not register this alongside `AnimationPlugin`'s win cascade** — they
|
|
||||||
/// will race on the same card entities. To use this plugin, prevent
|
|
||||||
/// `AnimationPlugin` from handling `GameWonEvent` (or remove it and manage
|
|
||||||
/// win toasts manually).
|
|
||||||
pub struct WinCascadePlugin;
|
|
||||||
|
|
||||||
impl Plugin for WinCascadePlugin {
|
|
||||||
fn build(&self, app: &mut App) {
|
|
||||||
app.add_systems(Update, trigger_expressive_win_cascade.after(GameMutation));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inserts `CardAnimation` (Expressive curve) on every card when `GameWonEvent` fires.
|
|
||||||
///
|
|
||||||
/// Cards scatter to 8 off-screen positions with per-card stagger. The z-lift
|
|
||||||
/// creates a "burst" effect as cards fly outward.
|
|
||||||
fn trigger_expressive_win_cascade(
|
|
||||||
mut events: MessageReader<GameWonEvent>,
|
|
||||||
cards: Query<(Entity, &Transform), With<CardEntity>>,
|
|
||||||
layout: Option<Res<LayoutResource>>,
|
|
||||||
mut commands: Commands,
|
|
||||||
) {
|
|
||||||
if events.read().next().is_none() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let radius = layout.as_ref().map_or(800.0, |l| l.0.card_size.x * 8.0);
|
|
||||||
|
|
||||||
let targets = win_scatter_targets(radius);
|
|
||||||
|
|
||||||
for (index, (entity, transform)) in cards.iter().enumerate() {
|
|
||||||
let start_xy = transform.translation.truncate();
|
|
||||||
let start_z = transform.translation.z;
|
|
||||||
let target = targets[index % targets.len()];
|
|
||||||
|
|
||||||
commands.entity(entity).insert(
|
|
||||||
CardAnimation::slide(
|
|
||||||
start_xy,
|
|
||||||
start_z,
|
|
||||||
target,
|
|
||||||
start_z + 60.0,
|
|
||||||
MotionCurve::Expressive,
|
|
||||||
)
|
)
|
||||||
.with_delay(cascade_delay(index, WIN_CASCADE_INTERVAL_SECS))
|
.configure_sets(
|
||||||
.with_duration(0.65)
|
Update,
|
||||||
.with_z_lift(25.0),
|
CardTweenFx
|
||||||
);
|
.after(GameMutation)
|
||||||
|
.after(crate::table_plugin::MarkerVisuals)
|
||||||
|
// HUD text pulses touch Transform on UI entities only —
|
||||||
|
// disjoint from the card entities this set animates
|
||||||
|
// (same rationale as UiTextFx × BoardVisuals, #143).
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,6 +244,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();
|
||||||
@@ -412,19 +392,4 @@ mod tests {
|
|||||||
let state = HoverState::default();
|
let state = HoverState::default();
|
||||||
assert!(state.entity.is_none());
|
assert!(state.entity.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn win_scatter_produces_eight_distinct_points() {
|
|
||||||
let targets = win_scatter_targets(600.0);
|
|
||||||
assert_eq!(targets.len(), 8);
|
|
||||||
// All must be different.
|
|
||||||
for i in 0..8 {
|
|
||||||
for j in (i + 1)..8 {
|
|
||||||
assert_ne!(
|
|
||||||
targets[i], targets[j],
|
|
||||||
"scatter targets {i} and {j} must be distinct"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,17 +49,6 @@ pub fn micro_vary(duration: f32, entity_index: u32) -> f32 {
|
|||||||
duration * (1.0 + variation)
|
duration * (1.0 + variation)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns the pre-animation delay for card at `index` in a staggered cascade.
|
|
||||||
///
|
|
||||||
/// `delay = index × interval_secs`.
|
|
||||||
#[inline]
|
|
||||||
pub fn cascade_delay(index: usize, interval_secs: f32) -> f32 {
|
|
||||||
index as f32 * interval_secs
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recommended per-card interval for the win cascade (Normal speed).
|
|
||||||
pub const WIN_CASCADE_INTERVAL_SECS: f32 = 0.018;
|
|
||||||
|
|
||||||
/// Recommended per-card interval for deal animations (Normal speed).
|
/// Recommended per-card interval for deal animations (Normal speed).
|
||||||
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
|
pub const DEAL_INTERVAL_SECS: f32 = 0.022;
|
||||||
|
|
||||||
@@ -137,22 +126,4 @@ mod tests {
|
|||||||
"micro_vary should differ for different indices"
|
"micro_vary should differ for different indices"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cascade_delay_zero_index_is_zero() {
|
|
||||||
assert_eq!(cascade_delay(0, 0.018), 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn cascade_delay_scales_linearly() {
|
|
||||||
let interval = 0.018;
|
|
||||||
for i in 0..52usize {
|
|
||||||
let expected = i as f32 * interval;
|
|
||||||
let actual = cascade_delay(i, interval);
|
|
||||||
assert!(
|
|
||||||
(actual - expected).abs() < 1e-6,
|
|
||||||
"cascade_delay({i}) = {actual}, expected {expected}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,197 @@
|
|||||||
|
//! Card flip animation and drag shadows.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use solitaire_core::Card;
|
||||||
|
|
||||||
|
use crate::animation_plugin::EffectiveSlideDuration;
|
||||||
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||||
|
use crate::layout::LayoutResource;
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z};
|
||||||
|
|
||||||
|
/// Listens for `CardFlippedEvent` and inserts a `CardFlipAnim` on the entity.
|
||||||
|
///
|
||||||
|
/// Skipped when `EffectiveSlideDuration::slide_secs == 0.0` (Instant speed).
|
||||||
|
pub(super) fn start_flip_anim(
|
||||||
|
mut events: MessageReader<CardFlippedEvent>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
card_entities: Query<(Entity, &CardEntity)>,
|
||||||
|
) {
|
||||||
|
if slide_dur.is_some_and(|d| d.slide_secs == 0.0) {
|
||||||
|
// Instant animation speed — skip the flip effect entirely.
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for CardFlippedEvent(flipped_card) in events.read() {
|
||||||
|
for (entity, marker) in &card_entities {
|
||||||
|
if marker.card == *flipped_card {
|
||||||
|
commands.entity(entity).insert(CardFlipAnim {
|
||||||
|
timer: 0.0,
|
||||||
|
phase: FlipPhase::ScalingDown,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances `CardFlipAnim` each frame, modifying `Transform::scale.x`.
|
||||||
|
///
|
||||||
|
/// - Phase `ScalingDown`: lerps scale.x from 1.0 → 0.0 over `FLIP_HALF_SECS`.
|
||||||
|
/// - At the midpoint the phase switches to `ScalingUp`, scale.x resets to 0,
|
||||||
|
/// and a `CardFaceRevealedEvent` is fired so audio plays in sync with the reveal.
|
||||||
|
/// - Phase `ScalingUp`: lerps scale.x from 0.0 → 1.0 over `FLIP_HALF_SECS`.
|
||||||
|
/// - When complete the component is removed and scale.x is restored to 1.0.
|
||||||
|
pub(super) fn tick_flip_anim(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut anims: Query<(Entity, &CardEntity, &mut Transform, &mut CardFlipAnim)>,
|
||||||
|
mut reveal_events: MessageWriter<CardFaceRevealedEvent>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, card_entity, mut transform, mut anim) in &mut anims {
|
||||||
|
anim.timer += dt;
|
||||||
|
match anim.phase {
|
||||||
|
FlipPhase::ScalingDown => {
|
||||||
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||||
|
transform.scale.x = 1.0 - t;
|
||||||
|
if t >= 1.0 {
|
||||||
|
anim.phase = FlipPhase::ScalingUp;
|
||||||
|
anim.timer = 0.0;
|
||||||
|
transform.scale.x = 0.0;
|
||||||
|
// Fire the reveal event exactly once, at the phase transition,
|
||||||
|
// so the flip sound is synchronised with the visual face reveal.
|
||||||
|
reveal_events.write(CardFaceRevealedEvent(card_entity.card.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FlipPhase::ScalingUp => {
|
||||||
|
let t = (anim.timer / FLIP_HALF_SECS).min(1.0);
|
||||||
|
transform.scale.x = t;
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale.x = 1.0;
|
||||||
|
commands.entity(entity).remove::<CardFlipAnim>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #38 — Drag-elevation shadow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Maintains a single `ShadowEntity` while cards are being dragged.
|
||||||
|
///
|
||||||
|
/// - If a drag is active, spawns (or repositions) a semi-transparent dark
|
||||||
|
/// sprite behind the top dragged card.
|
||||||
|
/// - If no drag is active, despawns the shadow entity.
|
||||||
|
pub(super) fn update_drag_shadow(
|
||||||
|
mut commands: Commands,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
card_entities: Query<(&CardEntity, &Transform)>,
|
||||||
|
card_index: Res<CardEntityIndex>,
|
||||||
|
mut shadow: Local<Option<Entity>>,
|
||||||
|
) {
|
||||||
|
if drag.is_idle() {
|
||||||
|
// No drag in progress — remove shadow if it exists.
|
||||||
|
if let Some(e) = shadow.take() {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let card_w = layout.0.card_size.x;
|
||||||
|
let card_h = layout.0.card_size.y;
|
||||||
|
|
||||||
|
// Find the world position of the first (top) dragged card.
|
||||||
|
let top_pos = drag.cards.first().and_then(|first_card| {
|
||||||
|
card_index
|
||||||
|
.get(first_card)
|
||||||
|
.and_then(|entity| card_entities.get(entity).ok())
|
||||||
|
.map(|(_, t)| t.translation)
|
||||||
|
});
|
||||||
|
|
||||||
|
let Some(top_pos) = top_pos else { return };
|
||||||
|
|
||||||
|
// Shadow is slightly larger, offset behind-and-below, at a z slightly
|
||||||
|
// below the dragged cards.
|
||||||
|
let shadow_pos = top_pos + Vec3::new(-4.0, 4.0, -1.0);
|
||||||
|
|
||||||
|
match *shadow {
|
||||||
|
Some(e) => {
|
||||||
|
// Reposition the existing shadow.
|
||||||
|
commands
|
||||||
|
.entity(e)
|
||||||
|
.insert(Transform::from_translation(shadow_pos));
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Spawn a new shadow sprite. Alpha tracks the per-card
|
||||||
|
// CARD_SHADOW_ALPHA_DRAG token so the Terminal palette's
|
||||||
|
// "no box-shadow" policy disables this stack shadow in
|
||||||
|
// lockstep with the per-card shadows. Re-enabling shadows
|
||||||
|
// is then a one-line change in `ui_theme`, not a hunt
|
||||||
|
// through plugin code.
|
||||||
|
let e = commands
|
||||||
|
.spawn((
|
||||||
|
ShadowEntity,
|
||||||
|
Sprite {
|
||||||
|
color: CARD_SHADOW_COLOR.with_alpha(CARD_SHADOW_ALPHA_DRAG),
|
||||||
|
custom_size: Some(Vec2::new(card_w + 8.0, card_h + 8.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_translation(shadow_pos),
|
||||||
|
Visibility::default(),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
*shadow = Some(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snaps every per-card [`CardShadow`] between its idle and lifted tunings
|
||||||
|
/// based on whether the parent [`CardEntity`] is currently in
|
||||||
|
/// [`DragState::cards`]. Runs every frame; the transition is an instant snap
|
||||||
|
/// (no lerp) — the existing shake / settle feedback already handles motion
|
||||||
|
/// at drag-end, so an additional shadow tween would compete with those cues.
|
||||||
|
///
|
||||||
|
/// The shadow size is rebuilt from the parent card's current `Sprite`
|
||||||
|
/// `custom_size` plus the appropriate padding, so the resize handler does
|
||||||
|
/// not need to pre-tune shadow sizes for the drag state — this system fixes
|
||||||
|
/// the geometry within one frame.
|
||||||
|
pub(super) fn update_card_shadows_on_drag(
|
||||||
|
drag: Res<DragState>,
|
||||||
|
cards: Query<(&CardEntity, &Sprite, &Children), Without<CardShadow>>,
|
||||||
|
mut shadows: Query<(&mut Sprite, &mut Transform), With<CardShadow>>,
|
||||||
|
) {
|
||||||
|
let dragged: HashSet<&Card> = drag.cards.iter().collect();
|
||||||
|
|
||||||
|
for (card_entity, card_sprite, children) in cards.iter() {
|
||||||
|
let is_dragged = dragged.contains(&card_entity.card);
|
||||||
|
let (offset, padding, alpha) = card_shadow_params(is_dragged);
|
||||||
|
let Some(card_size) = card_sprite.custom_size else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
for child in children.iter() {
|
||||||
|
let Ok((mut shadow_sprite, mut shadow_transform)) = shadows.get_mut(child) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
shadow_sprite.color = CARD_SHADOW_COLOR.with_alpha(alpha);
|
||||||
|
shadow_sprite.custom_size = Some(card_size + padding);
|
||||||
|
shadow_transform.translation.x = offset.x;
|
||||||
|
shadow_transform.translation.y = offset.y;
|
||||||
|
shadow_transform.translation.z = CARD_SHADOW_LOCAL_Z;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #28 — Hint highlight tick system
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
//! Hint and right-click highlights, plus cursor hit-testing helpers.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::resources::{DragState, GameStateResource};
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||||
|
|
||||||
|
/// Counts down `HintHighlight::remaining` each frame. When it reaches zero,
|
||||||
|
/// removes both `HintHighlight` and `HintHighlightTimer` (if present) and
|
||||||
|
/// resets the card sprite to its normal face-up colour.
|
||||||
|
pub(super) fn tick_hint_highlight(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut query: Query<(Entity, &mut HintHighlight, &mut Sprite, &CardEntity)>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
) {
|
||||||
|
let back_idx = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
|
let use_images = card_images.is_some();
|
||||||
|
for (entity, mut hint, mut sprite, card_entity) in query.iter_mut() {
|
||||||
|
hint.remaining -= time.delta_secs();
|
||||||
|
if hint.remaining <= 0.0 {
|
||||||
|
// Restore the normal sprite colour.
|
||||||
|
// When image-based rendering is active, WHITE is the neutral tint;
|
||||||
|
// otherwise restore the solid colour appropriate to the card state.
|
||||||
|
sprite.color = if use_images {
|
||||||
|
Color::WHITE
|
||||||
|
} else {
|
||||||
|
let is_face_up = all_cards(&game.0)
|
||||||
|
.iter()
|
||||||
|
.find(|(c, _face_up)| *c == card_entity.card)
|
||||||
|
.is_some_and(|(_, face_up)| *face_up);
|
||||||
|
if is_face_up {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
} else {
|
||||||
|
card_back_colour(back_idx)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<HintHighlight>()
|
||||||
|
.remove::<HintHighlightTimer>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #46 — Right-click legal destination highlights
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Lime tint applied to a `PileMarker` sprite when it is a legal
|
||||||
|
/// destination for the right-clicked card. Same RGB as the design-
|
||||||
|
/// system [`STATE_SUCCESS`] token at 60% alpha. Spelled as a literal
|
||||||
|
/// because `Alpha::with_alpha` is not yet a `const` trait method on
|
||||||
|
/// stable; the tracking test below pins the RGB to `STATE_SUCCESS`
|
||||||
|
/// so a palette swap can't drift the two apart silently.
|
||||||
|
pub(super) const RIGHT_CLICK_HIGHLIGHT_COLOUR: Color = Color::srgba(0.675, 0.761, 0.404, 0.6);
|
||||||
|
|
||||||
|
/// Counts down `RightClickHighlightTimer` each frame and clears the highlight
|
||||||
|
/// when the timer expires.
|
||||||
|
///
|
||||||
|
/// This is a fallback expiry: highlights also clear immediately on
|
||||||
|
/// `StateChangedEvent` (move made) or when the game is paused, whichever comes
|
||||||
|
/// first. The 1.5 s timer ensures highlights always disappear even if the
|
||||||
|
/// player takes no further action.
|
||||||
|
pub(super) fn tick_right_click_highlights(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut highlights: Query<
|
||||||
|
(Entity, &mut RightClickHighlightTimer, &mut Sprite),
|
||||||
|
With<RightClickHighlight>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut timer, mut sprite) in &mut highlights {
|
||||||
|
timer.0 -= dt;
|
||||||
|
if timer.0 <= 0.0 {
|
||||||
|
// Restore the pile marker to its default colour before removing
|
||||||
|
// the highlight marker component.
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<RightClickHighlight>()
|
||||||
|
.remove::<RightClickHighlightTimer>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the `RightClickHighlight` marker from every highlighted pile and
|
||||||
|
/// resets its sprite colour to `PILE_MARKER_DEFAULT_COLOUR`.
|
||||||
|
///
|
||||||
|
/// Shared by the on-state-change and on-pause clear systems to avoid
|
||||||
|
/// duplicating the removal logic.
|
||||||
|
pub(super) fn clear_right_click_highlights(
|
||||||
|
commands: &mut Commands,
|
||||||
|
highlighted: &Query<Entity, With<RightClickHighlight>>,
|
||||||
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
for entity in highlighted.iter() {
|
||||||
|
commands.entity(entity).remove::<RightClickHighlight>();
|
||||||
|
}
|
||||||
|
for (_entity, _, mut sprite) in pile_markers.iter_mut() {
|
||||||
|
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all right-click destination highlights whenever any game-state
|
||||||
|
/// mutation succeeds (`StateChangedEvent` fires).
|
||||||
|
///
|
||||||
|
/// This ensures stale highlights do not linger after a card is moved.
|
||||||
|
pub(super) fn clear_right_click_highlights_on_state_change(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clears all right-click destination highlights when the game is paused
|
||||||
|
/// (`PausedResource` changes to `true`).
|
||||||
|
///
|
||||||
|
/// Prevents highlighted pile markers from remaining visible behind the pause
|
||||||
|
/// overlay.
|
||||||
|
pub(super) fn clear_right_click_highlights_on_pause(
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
) {
|
||||||
|
let Some(paused) = paused else { return };
|
||||||
|
if paused.is_changed() && paused.0 {
|
||||||
|
clear_right_click_highlights(&mut commands, &highlighted, &mut pile_markers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handles right-click: highlights legal destination piles for the clicked card,
|
||||||
|
/// and clears highlights on any subsequent right- or left-click.
|
||||||
|
///
|
||||||
|
/// This system lives in `CardPlugin` to keep `InputPlugin` untouched.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_right_click(
|
||||||
|
buttons: Option<Res<ButtonInput<MouseButton>>>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
windows: Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
|
cameras: Query<(&Camera, &GlobalTransform)>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
highlighted: Query<Entity, With<RightClickHighlight>>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(buttons) = buttons else { return };
|
||||||
|
let left_pressed = buttons.just_pressed(MouseButton::Left);
|
||||||
|
let right_pressed = buttons.just_pressed(MouseButton::Right);
|
||||||
|
|
||||||
|
// Clear existing highlights on any click.
|
||||||
|
if left_pressed || right_pressed {
|
||||||
|
for entity in &highlighted {
|
||||||
|
commands.entity(entity).remove::<RightClickHighlight>();
|
||||||
|
}
|
||||||
|
for (_entity, _, mut sprite) in &mut pile_markers {
|
||||||
|
if sprite.color == RIGHT_CLICK_HIGHLIGHT_COLOUR {
|
||||||
|
sprite.color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only proceed for right-clicks while not dragging.
|
||||||
|
if !right_pressed || !drag.is_idle() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
|
||||||
|
// Convert cursor to world-space position.
|
||||||
|
let Some(world) = cursor_world_pos(&windows, &cameras) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Find the topmost face-up card under the cursor.
|
||||||
|
let Some(card) = find_top_card_at(world, &game.0, &layout.0, &card_entities) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Some(source_pile) = game.0.pile_containing_card(card.clone()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tint piles that legally accept the card.
|
||||||
|
for (entity, pile_marker, mut sprite) in &mut pile_markers {
|
||||||
|
let legal = game.0.can_move_cards(&source_pile, &pile_marker.0, 1);
|
||||||
|
if legal {
|
||||||
|
sprite.color = RIGHT_CLICK_HIGHLIGHT_COLOUR;
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.insert(RightClickHighlight)
|
||||||
|
.insert(RightClickHighlightTimer(1.5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Converts cursor position to 2-D world coordinates.
|
||||||
|
pub(super) fn cursor_world_pos(
|
||||||
|
windows: &Query<&Window, With<bevy::window::PrimaryWindow>>,
|
||||||
|
cameras: &Query<(&Camera, &GlobalTransform)>,
|
||||||
|
) -> Option<Vec2> {
|
||||||
|
let window = windows.single().ok()?;
|
||||||
|
let cursor = window.cursor_position()?;
|
||||||
|
let (camera, camera_transform) = cameras.single().ok()?;
|
||||||
|
camera.viewport_to_world_2d(camera_transform, cursor).ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the topmost face-up `Card` under `cursor` by checking axis-aligned
|
||||||
|
/// bounding rectangles of all card sprites, picking the highest Z.
|
||||||
|
pub(super) fn find_top_card_at(
|
||||||
|
cursor: Vec2,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
card_entities: &Query<(Entity, &CardEntity, &Transform)>,
|
||||||
|
) -> Option<Card> {
|
||||||
|
let half = layout.card_size / 2.0;
|
||||||
|
let mut best: Option<(f32, Card)> = None;
|
||||||
|
|
||||||
|
for (_, card_entity, transform) in card_entities.iter() {
|
||||||
|
let pos = transform.translation.truncate();
|
||||||
|
if cursor.x < pos.x - half.x
|
||||||
|
|| cursor.x > pos.x + half.x
|
||||||
|
|| cursor.y < pos.y - half.y
|
||||||
|
|| cursor.y > pos.y + half.y
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let found = all_cards(game)
|
||||||
|
.into_iter()
|
||||||
|
.find(|(c, face_up)| *c == card_entity.card && *face_up);
|
||||||
|
if let Some((card, _)) = found {
|
||||||
|
let z = transform.translation.z;
|
||||||
|
if best.as_ref().is_none_or(|(bz, _)| z > *bz) {
|
||||||
|
best = Some((z, card));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
best.map(|(_, card)| card)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #28 — Stock-empty visual indicator
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
//! Card face labels: desktop text labels and Android corner labels.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use bevy::sprite::Anchor;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
|
||||||
|
use crate::ui_theme::TEXT_PRIMARY_HC;
|
||||||
|
|
||||||
|
pub(super) fn label_for(card: &Card) -> String {
|
||||||
|
let rank = match card.rank() {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
};
|
||||||
|
let suit = match card.suit() {
|
||||||
|
Suit::Clubs => "C",
|
||||||
|
Suit::Diamonds => "D",
|
||||||
|
Suit::Hearts => "H",
|
||||||
|
Suit::Spades => "S",
|
||||||
|
};
|
||||||
|
format!("{rank}{suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suit colour for the rank/suit overlay rendered atop the constant
|
||||||
|
/// fallback sprite (only fires under `MinimalPlugins` — production
|
||||||
|
/// renders the suit glyph baked into the PNG). 2-colour traditional
|
||||||
|
/// pairing — hearts + diamonds share the saturated red, clubs +
|
||||||
|
/// spades share the near-white. Two accessibility flags compose:
|
||||||
|
///
|
||||||
|
/// - `color_blind`: red-suit cards swap to `RED_SUIT_COLOUR_CBM`
|
||||||
|
/// (lime) — the "Settings toggle swaps red→lime" half of the
|
||||||
|
/// design system's colour-blind support. CBM is a hue-replacement
|
||||||
|
/// for red, so HC has no further effect on red when CBM is on
|
||||||
|
/// (the lime is itself a high-luminance colour).
|
||||||
|
/// - `high_contrast`: when CBM is off, red suits boost to
|
||||||
|
/// `RED_SUIT_COLOUR_HC` (`#ff6868`); black suits boost from
|
||||||
|
/// `#e8e8e8` (near-white) to `#f5f5f5` (`TEXT_PRIMARY_HC`).
|
||||||
|
///
|
||||||
|
/// The other half of CBM support (always-on filled-vs-outlined
|
||||||
|
/// glyph differentiation for ♥♠ vs ♦♣) is baked into the PNG art
|
||||||
|
/// and has no constant-fallback equivalent.
|
||||||
|
pub(super) fn text_colour(card: &Card, color_blind: bool, high_contrast: bool) -> Color {
|
||||||
|
if card.suit().is_red() {
|
||||||
|
if color_blind {
|
||||||
|
// CBM lime wins — the colour-blind swap replaces the
|
||||||
|
// red hue entirely, and the lime is already high-
|
||||||
|
// luminance, so an HC boost on top has nothing to do.
|
||||||
|
RED_SUIT_COLOUR_CBM
|
||||||
|
} else if high_contrast {
|
||||||
|
RED_SUIT_COLOUR_HC
|
||||||
|
} else {
|
||||||
|
RED_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
} else if high_contrast {
|
||||||
|
TEXT_PRIMARY_HC
|
||||||
|
} else {
|
||||||
|
BLACK_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn label_visibility(face_up: bool) -> Visibility {
|
||||||
|
if face_up {
|
||||||
|
Visibility::Inherited
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rank+suit string for the readability overlay on touch HUD layouts.
|
||||||
|
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||||
|
pub(super) fn mobile_label_for(card: &Card) -> String {
|
||||||
|
let rank = match card.rank() {
|
||||||
|
Rank::Ace => "A",
|
||||||
|
Rank::Two => "2",
|
||||||
|
Rank::Three => "3",
|
||||||
|
Rank::Four => "4",
|
||||||
|
Rank::Five => "5",
|
||||||
|
Rank::Six => "6",
|
||||||
|
Rank::Seven => "7",
|
||||||
|
Rank::Eight => "8",
|
||||||
|
Rank::Nine => "9",
|
||||||
|
Rank::Ten => "10",
|
||||||
|
Rank::Jack => "J",
|
||||||
|
Rank::Queen => "Q",
|
||||||
|
Rank::King => "K",
|
||||||
|
};
|
||||||
|
let suit = match card.suit() {
|
||||||
|
Suit::Clubs => "♣",
|
||||||
|
Suit::Diamonds => "♦",
|
||||||
|
Suit::Hearts => "♥",
|
||||||
|
Suit::Spades => "♠",
|
||||||
|
};
|
||||||
|
format!("{rank}{suit}")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards. The background sprite covers the card art's own small
|
||||||
|
/// corner text so only the large overlay is visible.
|
||||||
|
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||||
|
/// face-up cards using FiraMono (passed via `font_handle`) so that the
|
||||||
|
/// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit
|
||||||
|
/// font handle Bevy falls back to its built-in face which does not include
|
||||||
|
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
||||||
|
/// the text colour — the root cause of the "red square on face-down cards"
|
||||||
|
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
||||||
|
pub(super) fn add_android_corner_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
card_size: Vec2,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
if !face_up {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
// Background covers ~3 monospace chars wide × 1 line tall.
|
||||||
|
// FiraMono char width ≈ 0.6 × font_size; 2.0× gives room for "10♠"
|
||||||
|
// (3 chars = 1.8× font_size) plus a small margin.
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
|
||||||
|
// Background covers the PNG's baked-in small corner text (top-left).
|
||||||
|
// Classic PNG cards have a white face, so the background must be white too.
|
||||||
|
// (CARD_FACE_COLOUR is the Terminal theme's dark face colour — wrong here.)
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
-card_size.x / 2.0 + inset + bg_w / 2.0,
|
||||||
|
card_size.y / 2.0 - inset - bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
// Cover the matching rotated baked-in text at the bottom-right corner.
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerBg,
|
||||||
|
Sprite {
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(Vec2::new(bg_w, bg_h)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(
|
||||||
|
card_size.x / 2.0 - inset - bg_w / 2.0,
|
||||||
|
-card_size.y / 2.0 + inset + bg_h / 2.0,
|
||||||
|
0.015,
|
||||||
|
),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||||
|
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||||
|
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||||
|
//
|
||||||
|
// Classic PNG cards have a white face: red suits stay the same saturated
|
||||||
|
// red, but black suits must use a dark colour (CARD_FACE_COLOUR ≈ #1a1a1a)
|
||||||
|
// rather than the near-white BLACK_SUIT_COLOUR designed for the dark
|
||||||
|
// Terminal theme background.
|
||||||
|
let text_col = if card.suit().is_red() {
|
||||||
|
if color_blind {
|
||||||
|
RED_SUIT_COLOUR_CBM
|
||||||
|
} else if high_contrast {
|
||||||
|
RED_SUIT_COLOUR_HC
|
||||||
|
} else {
|
||||||
|
RED_SUIT_COLOUR
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
};
|
||||||
|
let label_text = mobile_label_for(card);
|
||||||
|
parent.spawn((
|
||||||
|
AndroidCornerLabel(label_text.clone()),
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_text),
|
||||||
|
TextFont {
|
||||||
|
font: font_handle.cloned().unwrap_or_default(),
|
||||||
|
font_size,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_col),
|
||||||
|
Anchor::TOP_LEFT,
|
||||||
|
Transform::from_xyz(-card_size.x / 2.0 + inset, card_size.y / 2.0 - inset, 0.02),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #34 — Card-flip animation systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,346 @@
|
|||||||
|
//! Resize handling: window-resize snapping, in-place card resizing,
|
||||||
|
//! and tableau fan spread.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::animation_plugin::CardAnim;
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::table_plugin::PileMarker;
|
||||||
|
use crate::ui_theme::{CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_PADDING_DRAG, CARD_SHADOW_PADDING_IDLE};
|
||||||
|
|
||||||
|
/// Coalesces every `WindowResized` event arriving this frame into the latest
|
||||||
|
/// pending size on [`ResizeThrottle`].
|
||||||
|
///
|
||||||
|
/// `WindowResized` fires per pixel of resize drag, so a fast corner drag can
|
||||||
|
/// emit many events per frame. Reading `.last()` keeps only the final size —
|
||||||
|
/// every frame's snap target is the most recent window size, never a stale
|
||||||
|
/// one. Pending stays set across frames until the throttled applier consumes
|
||||||
|
/// it; that's how we still flush the final "release" position when the user
|
||||||
|
/// stops dragging.
|
||||||
|
pub(super) fn collect_resize_events(
|
||||||
|
mut events: MessageReader<WindowResized>,
|
||||||
|
mut throttle: ResMut<ResizeThrottle>,
|
||||||
|
) {
|
||||||
|
if let Some(ev) = events.read().last() {
|
||||||
|
throttle.pending = Some(Vec2::new(ev.width, ev.height));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snaps every card sprite to its target position, size, and (in the
|
||||||
|
/// fallback Text2d label path) font size when the window is resized.
|
||||||
|
///
|
||||||
|
/// **In-place mutation only.** Resize is the hot path — events fire per
|
||||||
|
/// pixel of drag, so this system cannot afford the despawn/respawn churn
|
||||||
|
/// `update_card_entity` does. We mutate `Sprite.custom_size`, `Transform`,
|
||||||
|
/// and child `TextFont.font_size` directly, leaving the card image handle,
|
||||||
|
/// suit/rank, and `CardLabel` entity untouched. Cards keep their identity
|
||||||
|
/// across resizes; only their size and position change. The full repaint
|
||||||
|
/// path lives in [`update_card_entity`] and is still used by every non-resize
|
||||||
|
/// caller (deals, moves, flips, settings toggles).
|
||||||
|
///
|
||||||
|
/// **Throttled to ~20 Hz.** [`ResizeThrottle::pending`] is consumed at most
|
||||||
|
/// once per [`RESIZE_THROTTLE_SECS`]. When events stop arriving, the next
|
||||||
|
/// tick past the throttle window flushes the final size and clears
|
||||||
|
/// `pending`, so the steady-state always matches the user's release size.
|
||||||
|
///
|
||||||
|
/// **Cancels in-flight slides.** Any `CardAnim` is removed so a mid-slide
|
||||||
|
/// tween is not retargeted relative to the previous card-size's position.
|
||||||
|
///
|
||||||
|
/// The "↺" stock-empty label's `font_size` is derived from
|
||||||
|
/// `layout.card_size.x`, so this system also reapplies the stock indicator —
|
||||||
|
/// otherwise the label would not rescale on resize.
|
||||||
|
///
|
||||||
|
/// Scheduled after [`collect_resize_events`] (which itself runs after
|
||||||
|
/// `LayoutSystem::UpdateOnResize`) so `LayoutResource` reflects the latest
|
||||||
|
/// window size before we read it.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
pub(super) fn snap_cards_on_window_resize(
|
||||||
|
mut commands: Commands,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut throttle: ResMut<ResizeThrottle>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
entities: Query<
|
||||||
|
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||||
|
(
|
||||||
|
Without<CardLabel>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||||
|
shadow_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardShadow>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
frame_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardBackFrame>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut pile_markers: Query<
|
||||||
|
(Entity, &PileMarker, &mut Sprite),
|
||||||
|
(
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
if throttle.pending.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let now = time.elapsed_secs();
|
||||||
|
if !should_apply_resize(now, throttle.last_applied_secs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let Some(game) = game else {
|
||||||
|
// Nothing to apply — clear pending so we don't busy-loop.
|
||||||
|
throttle.pending = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout else {
|
||||||
|
throttle.pending = None;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
resize_cards_in_place(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&layout.0,
|
||||||
|
card_images.as_deref(),
|
||||||
|
entities,
|
||||||
|
label_query,
|
||||||
|
shadow_query,
|
||||||
|
frame_query,
|
||||||
|
);
|
||||||
|
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
|
||||||
|
throttle.last_applied_secs = now;
|
||||||
|
throttle.pending = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-place "size-only" sibling of [`sync_cards`]: walks every existing card
|
||||||
|
/// entity, updates `Sprite.custom_size` and the snap-`Transform` to match the
|
||||||
|
/// fresh layout, and (in fallback solid-colour mode) also updates the child
|
||||||
|
/// `TextFont.font_size` of any `CardLabel`. No despawning, no `Sprite`
|
||||||
|
/// replacement, no children rebuild — that's the entire point of this path.
|
||||||
|
///
|
||||||
|
/// Called only from the resize handler. Game-state changes (deals, moves,
|
||||||
|
/// flips, settings toggles) still flow through [`sync_cards`] /
|
||||||
|
/// [`update_card_entity`], which handle add/remove/repaint correctly.
|
||||||
|
///
|
||||||
|
/// Any in-flight `CardAnim` slide is removed so a mid-tween card is not
|
||||||
|
/// retargeted relative to the previous card-size's position.
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
|
pub(super) fn resize_cards_in_place(
|
||||||
|
commands: &mut Commands,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
mut entities: Query<
|
||||||
|
(Entity, &CardEntity, &mut Sprite, &mut Transform),
|
||||||
|
(
|
||||||
|
Without<CardLabel>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut label_query: Query<&mut TextFont, (With<CardLabel>, Without<StockEmptyLabel>)>,
|
||||||
|
mut shadow_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardShadow>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
Without<CardBackFrame>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut frame_query: Query<
|
||||||
|
&mut Sprite,
|
||||||
|
(
|
||||||
|
With<CardBackFrame>,
|
||||||
|
Without<CardEntity>,
|
||||||
|
Without<CardShadow>,
|
||||||
|
Without<PileMarker>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let positions = card_positions(game, layout);
|
||||||
|
let pos_by_id: HashMap<Card, (Vec2, f32)> = positions
|
||||||
|
.into_iter()
|
||||||
|
.map(|((c, _face_up), p, z)| (c, (p, z)))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for (entity, marker, mut sprite, mut transform) in entities.iter_mut() {
|
||||||
|
let Some(&(pos, z)) = pos_by_id.get(&marker.card) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
sprite.custom_size = Some(layout.card_size);
|
||||||
|
transform.translation.x = pos.x;
|
||||||
|
transform.translation.y = pos.y;
|
||||||
|
transform.translation.z = z;
|
||||||
|
// Cancel any in-flight slide so it doesn't retarget from a stale
|
||||||
|
// mid-animation position computed against the previous card size.
|
||||||
|
commands.entity(entity).remove::<CardAnim>();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize every per-card shadow halo to match the new card size. Both
|
||||||
|
// idle and drag states scale with the card body, so we preserve the
|
||||||
|
// *current* padding (idle vs drag) by keeping the alpha as-is and only
|
||||||
|
// recomputing the geometry. The drag-tracking system runs every frame
|
||||||
|
// and will retune offset / alpha / padding-mode within one frame if the
|
||||||
|
// drag state diverges from the resized geometry.
|
||||||
|
let idle_padding = CARD_SHADOW_PADDING_IDLE;
|
||||||
|
let drag_padding = CARD_SHADOW_PADDING_DRAG;
|
||||||
|
for mut shadow_sprite in shadow_query.iter_mut() {
|
||||||
|
// Choose padding based on the shadow's current alpha — preserves
|
||||||
|
// a lifted shadow's larger halo across resize without needing to
|
||||||
|
// plumb DragState through the resize handler.
|
||||||
|
let alpha = shadow_sprite.color.alpha();
|
||||||
|
let padding = if alpha >= CARD_SHADOW_ALPHA_DRAG - 0.001 {
|
||||||
|
drag_padding
|
||||||
|
} else {
|
||||||
|
idle_padding
|
||||||
|
};
|
||||||
|
shadow_sprite.custom_size = Some(layout.card_size + padding);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the solid-colour fallback path uses CardLabel/Text2d overlays;
|
||||||
|
// when PNG faces are loaded the rank/suit are baked into the image and
|
||||||
|
// there is nothing to resize on the label side.
|
||||||
|
if card_images.is_none() {
|
||||||
|
let new_font_size = layout.card_size.x * FONT_SIZE_FRAC;
|
||||||
|
for mut font in label_query.iter_mut() {
|
||||||
|
font.font_size = new_font_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resize every face-down border frame to match the new card size.
|
||||||
|
let frame_size = layout.card_size + Vec2::splat(CARD_BACK_FRAME_PADDING);
|
||||||
|
for mut frame_sprite in frame_query.iter_mut() {
|
||||||
|
frame_sprite.custom_size = Some(frame_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates font size and top-left anchor transform of every
|
||||||
|
/// [`AndroidCornerLabel`] entity when `LayoutResource` changes (orientation
|
||||||
|
/// change or any window resize). The full despawn/respawn path in
|
||||||
|
/// `update_card_entity` already handles game-state changes; this system
|
||||||
|
/// covers the resize-only path where children are mutated in place.
|
||||||
|
pub(super) fn resize_android_corner_labels(
|
||||||
|
layout: Res<LayoutResource>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
mut text_query: Query<(
|
||||||
|
&AndroidCornerLabel,
|
||||||
|
&mut Text2d,
|
||||||
|
&mut TextFont,
|
||||||
|
&mut Transform,
|
||||||
|
)>,
|
||||||
|
mut bg_query: Query<(&mut Sprite, &mut Transform), AndroidCornerBgFilter>,
|
||||||
|
) {
|
||||||
|
if !layout.is_changed() || card_images.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let font_size = layout.0.card_size.x * FONT_SIZE_FRAC_MOBILE;
|
||||||
|
let inset = 3.0_f32;
|
||||||
|
let bg_w = font_size * 2.0;
|
||||||
|
let bg_h = font_size * 1.25;
|
||||||
|
let text_x = -layout.0.card_size.x / 2.0 + inset;
|
||||||
|
let text_y = layout.0.card_size.y / 2.0 - inset;
|
||||||
|
|
||||||
|
for (label, mut text2d, mut font, mut transform) in text_query.iter_mut() {
|
||||||
|
text2d.0 = label.0.clone();
|
||||||
|
font.font_size = font_size;
|
||||||
|
transform.translation.x = text_x;
|
||||||
|
transform.translation.y = text_y;
|
||||||
|
}
|
||||||
|
for (mut sprite, mut transform) in bg_query.iter_mut() {
|
||||||
|
sprite.custom_size = Some(Vec2::new(bg_w, bg_h));
|
||||||
|
transform.translation.x = text_x + bg_w / 2.0;
|
||||||
|
transform.translation.y = text_y - bg_h / 2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Adjusts `LayoutResource.tableau_fan_frac` (and the face-down companion) so
|
||||||
|
/// the deepest tableau column fills the available vertical space at every stage
|
||||||
|
/// of play. Runs after every `StateChangedEvent`.
|
||||||
|
///
|
||||||
|
/// Depth is measured across *all* cards in a column, weighting each face-down
|
||||||
|
/// card by the fixed face-down/face-up step ratio. Counting the face-down
|
||||||
|
/// portion — not just the face-up tail — is what fills the lower screen on a
|
||||||
|
/// fresh deal (the deepest column is then six face-down cards under one face-up
|
||||||
|
/// one): the earlier face-up-only depth was 1, so the fan never spread and the
|
||||||
|
/// bottom half of a near-square viewport (e.g. an unfolded foldable) sat empty.
|
||||||
|
///
|
||||||
|
/// Deeper columns drive the fraction down so everything still fits the window;
|
||||||
|
/// [`crate::layout::TABLEAU_FAN_FRAC`] floors it to the desktop feel and
|
||||||
|
/// [`MAX_DYNAMIC_FAN_FRAC`] caps it so a near-empty column doesn't fling its few
|
||||||
|
/// cards far apart.
|
||||||
|
pub(super) fn update_tableau_fan_frac(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut layout: Option<ResMut<LayoutResource>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(game) = game else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PostStartup sibling of [`update_tableau_fan_frac`]. The initial deal is
|
||||||
|
/// inserted directly as `GameStateResource` at startup without a
|
||||||
|
/// `StateChangedEvent`, so the event-driven system never fires for it. This
|
||||||
|
/// runs once, before [`sync_cards_startup`] renders, so the very first board
|
||||||
|
/// (cold start) already fills the viewport — otherwise a fresh deal on a tall /
|
||||||
|
/// near-square screen (e.g. an unfolded foldable) renders with the unspread fan
|
||||||
|
/// and a large empty band below the tableau until the first move.
|
||||||
|
pub(super) fn fill_tableau_fan_on_startup(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut layout: Option<ResMut<LayoutResource>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(layout) = layout.as_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
crate::layout::apply_dynamic_tableau_fan(&game.0, &mut layout.0);
|
||||||
|
}
|
||||||
@@ -0,0 +1,614 @@
|
|||||||
|
//! PNG-based card rendering.
|
||||||
|
//!
|
||||||
|
//! Card entities are synced with [`GameStateResource`] on every
|
||||||
|
//! [`StateChangedEvent`]: missing cards are spawned, present cards are
|
||||||
|
//! repositioned/updated in place, and stale cards are despawned.
|
||||||
|
//!
|
||||||
|
//! When [`CardImageSet`] is available, each face-up card renders its own
|
||||||
|
//! 120×168 px `Handle<Image>` chosen from the 52 per-card PNGs loaded from
|
||||||
|
//! `assets/cards/faces/{rank}_{suit}.png`. A solid-colour `Sprite` with a
|
||||||
|
//! `Text2d` rank+suit overlay is used as a fallback when `CardImageSet` is
|
||||||
|
//! absent (e.g. in tests running under `MinimalPlugins`).
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
|
|
||||||
|
use crate::card_animation::CardAnimation;
|
||||||
|
use crate::events::{CardFaceRevealedEvent, CardFlippedEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::layout::{Layout, LayoutSystem};
|
||||||
|
|
||||||
|
mod anim;
|
||||||
|
mod highlights;
|
||||||
|
mod labels;
|
||||||
|
mod layout;
|
||||||
|
mod stock;
|
||||||
|
mod sync;
|
||||||
|
|
||||||
|
use anim::*;
|
||||||
|
use highlights::*;
|
||||||
|
use labels::*;
|
||||||
|
use layout::*;
|
||||||
|
use stock::*;
|
||||||
|
use sync::*;
|
||||||
|
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::SettingsChangedEvent;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
CARD_SHADOW_ALPHA_DRAG, CARD_SHADOW_ALPHA_IDLE, CARD_SHADOW_COLOR, CARD_SHADOW_LOCAL_Z,
|
||||||
|
CARD_SHADOW_OFFSET_DRAG, CARD_SHADOW_OFFSET_IDLE, CARD_SHADOW_PADDING_DRAG,
|
||||||
|
CARD_SHADOW_PADDING_IDLE,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Per-card vertical step for face-down tableau cards, as a fraction of
|
||||||
|
/// card height. Smaller than [`crate::layout::TABLEAU_FAN_FRAC`] because face-down cards
|
||||||
|
/// don't need their full body shown — only the back-pattern strip is
|
||||||
|
/// visible. Public so `input_plugin` can mirror the exact sprite layout
|
||||||
|
/// when hit-testing tableau columns; any drift between this and the
|
||||||
|
/// renderer creates a visible offset between the card face and where
|
||||||
|
/// clicks land.
|
||||||
|
///
|
||||||
|
/// Matches `layout::TABLEAU_FACEDOWN_FAN_FRAC` (0.14). Both constants must
|
||||||
|
/// stay in sync; the layout constant drives the adaptive LayoutResource value
|
||||||
|
/// used at runtime, while this one is the minimum floor used by
|
||||||
|
/// `update_tableau_fan_frac` when computing proportional updates.
|
||||||
|
pub const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||||
|
|
||||||
|
/// Fraction of card height used as a tiny offset between stacked cards in
|
||||||
|
/// non-tableau piles, so stacking is visible. Public so other plugins
|
||||||
|
/// (e.g. input_plugin's drag-rejection tween) can compute the resting
|
||||||
|
/// `Transform.translation.z` for a card at a given stack index without
|
||||||
|
/// drifting from the value used by [`card_positions`].
|
||||||
|
// Must exceed the highest child local-z of any card entity (0.02 for the
|
||||||
|
// Android corner label) so every card's sprite covers all children of the
|
||||||
|
// card below it. Raising from 0.003 → 0.025 fixes corner labels on
|
||||||
|
// foundation piles bleeding through when a 2 sits on an Ace.
|
||||||
|
pub const STACK_FAN_FRAC: f32 = 0.025;
|
||||||
|
|
||||||
|
/// Per-card horizontal fan step for the Draw-Three waste, in logical pixels.
|
||||||
|
///
|
||||||
|
/// Derived from the actual tableau column spacing (`Tableau2.x − Tableau1.x`)
|
||||||
|
/// rather than a fixed fraction of card width, so the fan scales with the
|
||||||
|
/// platform's `H_GAP_DIVISOR` (desktop ≈ 1.25×cw spacing, Android ≈ 1.03×cw).
|
||||||
|
/// Public so `input_plugin` can hit-test the fanned waste cards at the exact
|
||||||
|
/// x-offsets the renderer uses; any drift makes a click on the top fanned card
|
||||||
|
/// land on the card beneath it.
|
||||||
|
pub fn waste_fan_step(layout: &Layout) -> f32 {
|
||||||
|
tableau_col_step(layout) * 0.224
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Horizontal distance between adjacent tableau columns (`Tableau2.x −
|
||||||
|
/// Tableau1.x`), in logical pixels. The face-down stock is rendered one column
|
||||||
|
/// step left of the waste, and the Draw-Three waste fan ([`waste_fan_step`]) is
|
||||||
|
/// a fraction of it. Public so hit-testing mirrors the renderer exactly.
|
||||||
|
pub fn tableau_col_step(layout: &Layout) -> f32 {
|
||||||
|
let t1 = layout
|
||||||
|
.pile_positions
|
||||||
|
.get(&KlondikePile::Tableau(Tableau::Tableau1))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
let t2 = layout
|
||||||
|
.pile_positions
|
||||||
|
.get(&KlondikePile::Tableau(Tableau::Tableau2))
|
||||||
|
.copied()
|
||||||
|
.unwrap_or_default();
|
||||||
|
(t2.x - t1.x).abs()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Font size as a fraction of card width.
|
||||||
|
const FONT_SIZE_FRAC: f32 = 0.28;
|
||||||
|
|
||||||
|
/// Font-size fraction for the large-print readability overlay on touch HUD layouts.
|
||||||
|
/// Spawned on top of PNG face cards to make the rank+suit legible at phone
|
||||||
|
/// scale, where the baked-in PNG corner text is only ~10 px physical.
|
||||||
|
const FONT_SIZE_FRAC_MOBILE: f32 = 0.35;
|
||||||
|
|
||||||
|
/// Card-face background — Terminal `#1a1a1a` (BG_ELEVATED).
|
||||||
|
pub const CARD_FACE_COLOUR: Color = Color::srgb(0.102, 0.102, 0.102);
|
||||||
|
/// Suit colour for hearts + diamonds — saturated red `#e35353`.
|
||||||
|
/// 2-colour traditional pairing (the "Microsoft Solitaire on dark
|
||||||
|
/// mode" feel) replacing the brief 4-colour-deck experiment that
|
||||||
|
/// shipped between v0.21.0 and this commit. Brighter and more
|
||||||
|
/// saturated than the v0.21.0 pink `#fb9fb1` so the cards read as
|
||||||
|
/// a "real solitaire deck" rather than a Terminal-pastel theme.
|
||||||
|
/// Visually distinct from `ACCENT_PRIMARY` (`#a54242` brick red,
|
||||||
|
/// darker) so chrome and suit don't read as the same hue.
|
||||||
|
pub const RED_SUIT_COLOUR: Color = Color::srgb(0.890, 0.325, 0.325);
|
||||||
|
/// High-contrast variant of [`RED_SUIT_COLOUR`] — `#ff6868`. Lifted
|
||||||
|
/// luminance for the Settings → Accessibility → High-contrast mode
|
||||||
|
/// toggle. Pre-2-colour-revert this was `#ff8aa0` (pink-salmon)
|
||||||
|
/// matching the v0.21.0 pink default; rebumped to a brighter red
|
||||||
|
/// so it reads as "more chromatic" than the new saturated default,
|
||||||
|
/// not "less saturated." Independent of `RED_SUIT_COLOUR_CBM`
|
||||||
|
/// (lime) — high-contrast is *additive* over the default colour
|
||||||
|
/// palette; CBM is a *replacement* of red with a hue-distinct
|
||||||
|
/// alternative. The two modes can stack; CBM wins when both are on
|
||||||
|
/// because the CBM lime is itself a high-contrast colour.
|
||||||
|
pub const RED_SUIT_COLOUR_HC: Color = Color::srgb(1.000, 0.408, 0.408);
|
||||||
|
/// Suit colour for spades + clubs — near-white `#e8e8e8`. Brighter
|
||||||
|
/// than `TEXT_PRIMARY` (`#d0d0d0`, foreground gray) so the
|
||||||
|
/// "black suit" reads as a distinct, chromatic-neutral counterpart
|
||||||
|
/// to the new saturated red, not as "the same gray as body text."
|
||||||
|
/// `TEXT_PRIMARY_HC` (`#f5f5f5`) is still brighter for the
|
||||||
|
/// high-contrast boost path.
|
||||||
|
pub const BLACK_SUIT_COLOUR: Color = Color::srgb(0.910, 0.910, 0.910);
|
||||||
|
|
||||||
|
/// Canonical outer index of `s` in [`CardImageSet::faces`].
|
||||||
|
///
|
||||||
|
/// Derived from the upstream `card_game::Suit` discriminants (0..=3 in
|
||||||
|
/// `Suit::SUITS` order), so every reader and writer of `faces` computes
|
||||||
|
/// the same layout from the same source. Three hand-rolled copies of this
|
||||||
|
/// mapping once lived in card_plugin and theme/plugin and were one
|
||||||
|
/// reorder away from drawing the wrong art.
|
||||||
|
pub(crate) const fn suit_index(s: solitaire_core::Suit) -> usize {
|
||||||
|
s as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Canonical inner index of `r` in [`CardImageSet::faces`] — upstream
|
||||||
|
/// `card_game::Rank` discriminants are 1..=13 in `Rank::RANKS` order.
|
||||||
|
pub(crate) const fn rank_index(r: solitaire_core::Rank) -> usize {
|
||||||
|
r as usize - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-loaded [`Handle<Image>`]s for card face and back PNG textures.
|
||||||
|
///
|
||||||
|
/// Loaded once at startup by [`load_card_images`]. When this resource is
|
||||||
|
/// present, card sprites use the PNG artwork; otherwise they fall back to
|
||||||
|
/// solid-colour sprites (used in tests with `MinimalPlugins`).
|
||||||
|
#[derive(Resource)]
|
||||||
|
pub struct CardImageSet {
|
||||||
|
/// Per-card face images indexed by `[suit][rank]`.
|
||||||
|
///
|
||||||
|
/// Layout is pinned to the upstream declaration order — index with
|
||||||
|
/// [`suit_index`] / [`rank_index`], never a hand-rolled match.
|
||||||
|
/// Suit order: `Suit::SUITS` (Spades=0, Hearts=1, Clubs=2, Diamonds=3).
|
||||||
|
/// Rank order: `Rank::RANKS` (Ace=0 … King=12).
|
||||||
|
pub faces: [[Handle<Image>; 13]; 4],
|
||||||
|
/// One handle per unlockable card-back design (indices 0–4). These
|
||||||
|
/// correspond to the legacy `assets/cards/backs/back_N.png` art, indexed
|
||||||
|
/// by `Settings::selected_card_back`. Used as a fallback when the active
|
||||||
|
/// theme does not provide its own back (see [`Self::theme_back`]).
|
||||||
|
pub backs: [Handle<Image>; 5],
|
||||||
|
/// Back image supplied by the currently-active card theme, if any.
|
||||||
|
///
|
||||||
|
/// Populated by `theme::plugin::apply_theme_to_card_image_set` whenever
|
||||||
|
/// a `CardTheme` finishes loading. The face-down render path in
|
||||||
|
/// [`card_sprite`] prefers this handle over the legacy `backs[]` array,
|
||||||
|
/// so a theme switch swaps both faces *and* the back without the player
|
||||||
|
/// needing to touch the legacy `selected_card_back` picker. `None` means
|
||||||
|
/// the active theme did not declare a back asset (or no theme has loaded
|
||||||
|
/// yet); in that case [`card_sprite`] falls back to the legacy array.
|
||||||
|
pub theme_back: Option<Handle<Image>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Suit-colour swap for red-suit cards in colour-blind mode — Terminal
|
||||||
|
/// `#acc267` (lime). Replaces `RED_SUIT_COLOUR` (pink) when CBM is on,
|
||||||
|
/// providing a hue-distinct alternative that survives the most common
|
||||||
|
/// red/green deficiencies. Pre-Terminal this was a *face tint*; the new
|
||||||
|
/// design moves CBM differentiation into the suit glyph colour itself
|
||||||
|
/// and keeps the face uniformly `CARD_FACE_COLOUR` regardless of CBM.
|
||||||
|
///
|
||||||
|
/// The CBM swap is lime (not the `ACCENT_PRIMARY` brick-red) because
|
||||||
|
/// the primary accent is itself in the red family — using it for
|
||||||
|
/// "the not-red CBM alternative" would defeat the purpose. Lime is
|
||||||
|
/// the next-best non-red base16-eighties accent; deuteranopia and
|
||||||
|
/// protanopia readers see it as visibly distinct from pink.
|
||||||
|
const RED_SUIT_COLOUR_CBM: Color = Color::srgb(0.675, 0.761, 0.404);
|
||||||
|
|
||||||
|
/// Returns the fallback card-back colour for the given unlocked card-back
|
||||||
|
/// index. Production renders backs from PNG artwork; this fallback only
|
||||||
|
/// fires under `MinimalPlugins` (tests). Mirrors the 5 accent colours
|
||||||
|
/// from `card_face_svg::BACK_ACCENTS` so the test-environment back lives
|
||||||
|
/// in the same hue family as the on-disk PNG art for that index.
|
||||||
|
fn card_back_colour(selected_card_back: usize) -> Color {
|
||||||
|
match selected_card_back {
|
||||||
|
0 => Color::srgb(0.647, 0.259, 0.259), // #a54242 brick red (Terminal canonical, ACCENT_PRIMARY)
|
||||||
|
1 => Color::srgb(0.675, 0.761, 0.404), // #acc267 lime
|
||||||
|
2 => Color::srgb(0.882, 0.639, 0.933), // #e1a3ee lavender
|
||||||
|
3 => Color::srgb(0.984, 0.624, 0.694), // #fb9fb1 pink
|
||||||
|
_ => Color::srgb(0.867, 0.698, 0.435), // #ddb26f gold (4+)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker component linking a Bevy entity to its `solitaire_core::Card`.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct CardEntity {
|
||||||
|
pub card: Card,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cached signature of the inputs that determine a card entity's *child*
|
||||||
|
/// visuals (drop-shadow, border frame, and the rank/suit label / large-print
|
||||||
|
/// corner overlay). Stored on each card so [`update_card_entity`] can skip the
|
||||||
|
/// expensive `despawn_related::<Children>()` + child respawn when nothing about
|
||||||
|
/// the appearance changed — the common case during a move, where only the
|
||||||
|
/// card's position changes. Without this guard every `StateChangedEvent`
|
||||||
|
/// rebuilds all 52 cards' children (≈250 entity spawn/despawns plus 52 `Text2d`
|
||||||
|
/// glyph re-layouts) in a single frame, which stutters the slide animation on
|
||||||
|
/// high-resolution devices.
|
||||||
|
///
|
||||||
|
/// The children depend only on these four inputs; the card identity is fixed
|
||||||
|
/// per entity, and the face/back *image* is handled by the always-refreshed
|
||||||
|
/// `Sprite` (a cheap handle swap), so theme/card-back changes need no child
|
||||||
|
/// rebuild.
|
||||||
|
#[derive(Component, Clone, Copy, PartialEq)]
|
||||||
|
struct CardChildrenKey {
|
||||||
|
face_up: bool,
|
||||||
|
card_size: Vec2,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query data read by the card-sync systems for each live card entity:
|
||||||
|
/// its id, card identity, current transform, any in-flight curve animation,
|
||||||
|
/// and its cached child-appearance key. Factored into an alias to keep the
|
||||||
|
/// system signatures readable (and satisfy clippy's `type_complexity`).
|
||||||
|
type CardSyncData = (
|
||||||
|
Entity,
|
||||||
|
&'static CardEntity,
|
||||||
|
&'static Transform,
|
||||||
|
Option<&'static CardAnimation>,
|
||||||
|
Option<&'static CardChildrenKey>,
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Render-side index mapping each live board card to its [`CardEntity`].
|
||||||
|
///
|
||||||
|
/// Maintained exclusively by [`rebuild_card_entity_index`] in `PostUpdate`,
|
||||||
|
/// after the card-sync authority ([`sync_cards_on_change`]) and the resize
|
||||||
|
/// re-snap have flushed their spawn/despawn `Commands`. Consumers treat it as
|
||||||
|
/// read-only and must still call `Query::get(entity)` for components beyond the
|
||||||
|
/// `Entity` id (the map yields only the id).
|
||||||
|
///
|
||||||
|
/// Keyed by `Card`, which is unique across all live board entities in
|
||||||
|
/// single-deck Klondike. Transient entities (drag shadow, labels) carry no
|
||||||
|
/// `CardEntity` component, so the rebuild — which scans `&CardEntity` — never
|
||||||
|
/// records them.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct CardEntityIndex(pub HashMap<Card, Entity>);
|
||||||
|
|
||||||
|
impl CardEntityIndex {
|
||||||
|
/// Resolve a card to its live entity, if one is currently spawned.
|
||||||
|
#[inline]
|
||||||
|
pub fn get(&self, card: &Card) -> Option<Entity> {
|
||||||
|
self.0.get(card).copied()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker for the text child inside a card entity.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CardLabel;
|
||||||
|
|
||||||
|
/// Marker for the large-print rank+suit corner overlay used by touch HUD layouts.
|
||||||
|
///
|
||||||
|
/// Spawned on top of PNG face cards (face-up only) at font size
|
||||||
|
/// [`FONT_SIZE_FRAC_MOBILE`] so the rank and suit character are
|
||||||
|
/// readable at phone scale. Only exists when `CardImageSet` is present
|
||||||
|
/// (the fallback solid-colour path uses a plain `CardLabel` instead).
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
struct AndroidCornerLabel(pub String);
|
||||||
|
|
||||||
|
/// Solid-colour background sprite behind [`AndroidCornerLabel`].
|
||||||
|
///
|
||||||
|
/// Covers the card art's own small corner rank/suit text so only the
|
||||||
|
/// large overlay is visible. Sized at [`FONT_SIZE_FRAC_MOBILE`]-derived
|
||||||
|
/// dimensions and coloured [`CARD_FACE_COLOUR`] to match the card face.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
struct AndroidCornerBg;
|
||||||
|
|
||||||
|
type AndroidCornerBgFilter = (With<AndroidCornerBg>, Without<AndroidCornerLabel>);
|
||||||
|
|
||||||
|
/// Marker component indicating the card is currently highlighted as a hint.
|
||||||
|
/// `remaining` counts down in real seconds; the highlight is removed when it
|
||||||
|
/// reaches zero and the card sprite colour is restored to its normal value.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct HintHighlight {
|
||||||
|
/// Seconds remaining before the highlight is cleared.
|
||||||
|
pub remaining: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Countdown (seconds) until the `HintHighlight` on a card entity is removed.
|
||||||
|
///
|
||||||
|
/// Inserted alongside `HintHighlight` by the hint-visual system. When the timer
|
||||||
|
/// reaches zero both `HintHighlight` and `HintHighlightTimer` are removed from
|
||||||
|
/// the entity and the sprite colour is restored.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct HintHighlightTimer(pub f32);
|
||||||
|
|
||||||
|
/// Marker on a `PileMarker` entity that is highlighted because the right-clicked
|
||||||
|
/// card can legally be placed there.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct RightClickHighlight;
|
||||||
|
|
||||||
|
/// Countdown (seconds) until this right-click destination highlight despawns.
|
||||||
|
///
|
||||||
|
/// Inserted alongside `RightClickHighlight` so that highlights auto-clear after
|
||||||
|
/// 1.5 s even if the player does not make a move or click again. The existing
|
||||||
|
/// clear-on-state-change and clear-on-pause logic still fires early when
|
||||||
|
/// appropriate.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct RightClickHighlightTimer(pub f32);
|
||||||
|
|
||||||
|
/// Marker placed on the child `Text2d` entity that shows "↺" on the stock pile
|
||||||
|
/// marker when the stock pile is empty.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct StockEmptyLabel;
|
||||||
|
|
||||||
|
/// Marker on the chip-background sprite of the stock-pile remaining-count
|
||||||
|
/// badge.
|
||||||
|
///
|
||||||
|
/// The badge is spawned as a *top-level* world entity (not parented to the
|
||||||
|
/// stock [`PileMarker`]) and its `Transform` is recomputed each frame from
|
||||||
|
/// `LayoutResource` so it tracks the stock pile through window resizes.
|
||||||
|
/// The chip sits in the bottom-right corner of the stock pile and is hidden
|
||||||
|
/// while the stock is empty — the existing `↺` overlay
|
||||||
|
/// ([`StockEmptyLabel`]) covers the recycle hint instead, so the two
|
||||||
|
/// indicators never render simultaneously.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct StockCountBadge;
|
||||||
|
|
||||||
|
/// Marker on the `Text2d` child of [`StockCountBadge`] showing the numeric
|
||||||
|
/// count of cards remaining in the stock pile.
|
||||||
|
///
|
||||||
|
/// Update systems query this component to write the new count in place rather
|
||||||
|
/// than despawning and respawning the text entity each tick.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct StockCountBadgeText;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #34 — Card-flip animation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Phase of the two-stage flip animation.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum FlipPhase {
|
||||||
|
/// Scale X from 1.0 → 0.0 (hiding the back face).
|
||||||
|
ScalingDown,
|
||||||
|
/// Scale X from 0.0 → 1.0 (revealing the front face).
|
||||||
|
ScalingUp,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives a 2-phase "card flip" animation on `CardEntity` entities.
|
||||||
|
///
|
||||||
|
/// The animation squashes X to 0, swaps the sprite to the face-up colour,
|
||||||
|
/// then expands X back to 1. Total duration is `2 × FLIP_HALF_SECS`.
|
||||||
|
#[derive(Component, Debug, Clone)]
|
||||||
|
pub struct CardFlipAnim {
|
||||||
|
/// Seconds elapsed in the current phase.
|
||||||
|
pub timer: f32,
|
||||||
|
/// Which half of the flip we are in.
|
||||||
|
pub phase: FlipPhase,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Duration of each half of the flip animation (scale-down or scale-up).
|
||||||
|
const FLIP_HALF_SECS: f32 = 0.08;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #38 — Drag-elevation shadow
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Marker component for the semi-transparent shadow sprite shown while dragging.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ShadowEntity;
|
||||||
|
|
||||||
|
/// Marker component for the per-card drop-shadow child sprite.
|
||||||
|
///
|
||||||
|
/// Every `CardEntity` owns exactly one `CardShadow` child whose `Sprite` is a
|
||||||
|
/// neutral-black halo painted slightly down-and-right of the card. Idle state
|
||||||
|
/// uses [`CARD_SHADOW_OFFSET_IDLE`] / [`CARD_SHADOW_ALPHA_IDLE`]; while the
|
||||||
|
/// parent card is being dragged the shadow is pushed to the deeper
|
||||||
|
/// [`CARD_SHADOW_OFFSET_DRAG`] / [`CARD_SHADOW_ALPHA_DRAG`] values so the
|
||||||
|
/// stack reads as "lifted" off the felt.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CardShadow;
|
||||||
|
|
||||||
|
/// Marker on the thin contrasting border sprite spawned behind face-down cards.
|
||||||
|
///
|
||||||
|
/// Face-down cards use `back_0.png` which is near-black (`#1a1a1a`). On the
|
||||||
|
/// dark-green felt the edges are nearly invisible. This child sprite — slightly
|
||||||
|
/// larger than the card, rendered at local z=-0.01 so it peeks out as a thin
|
||||||
|
/// frame — gives every face-down card a visible perimeter.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CardBackFrame;
|
||||||
|
|
||||||
|
/// Fill colour for the face-down card border frame. Light-medium gray so it
|
||||||
|
/// reads as a clear "edge" without competing with the suit colours on face-up
|
||||||
|
/// cards. Brightened from `0.38` to `0.48` (≈ #7a7a7a) after a Pixel_7 smoke
|
||||||
|
/// test showed face-down `back_0.png` (≈ #1a1a1a) was nearly invisible against
|
||||||
|
/// the very dark `#151515` felt — the old gray was too close to the back fill
|
||||||
|
/// to define a crisp perimeter.
|
||||||
|
const CARD_BACK_FRAME_COLOR: Color = Color::srgb(0.48, 0.48, 0.48);
|
||||||
|
|
||||||
|
/// Extra width/height (in world units) added to each side of the card to form
|
||||||
|
/// the visible border. Widened from `3.0` to `6.0` so the frame peeks out as a
|
||||||
|
/// clearly readable perimeter at phone density (420 dpi) rather than a hairline.
|
||||||
|
const CARD_BACK_FRAME_PADDING: f32 = 6.0;
|
||||||
|
|
||||||
|
/// Returns the `(offset, padding, alpha)` triple used to paint a per-card
|
||||||
|
/// shadow given whether its parent card is currently part of the dragged
|
||||||
|
/// stack. Pulled out as a pure helper so the shadow tuning can be unit-tested
|
||||||
|
/// without spinning up a Bevy app.
|
||||||
|
///
|
||||||
|
/// `is_dragged = false` → resting `(IDLE, IDLE, IDLE)`
|
||||||
|
/// `is_dragged = true` → lifted `(DRAG, DRAG, DRAG)`
|
||||||
|
pub fn card_shadow_params(is_dragged: bool) -> (Vec2, Vec2, f32) {
|
||||||
|
if is_dragged {
|
||||||
|
(
|
||||||
|
CARD_SHADOW_OFFSET_DRAG,
|
||||||
|
CARD_SHADOW_PADDING_DRAG,
|
||||||
|
CARD_SHADOW_ALPHA_DRAG,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
CARD_SHADOW_OFFSET_IDLE,
|
||||||
|
CARD_SHADOW_PADDING_IDLE,
|
||||||
|
CARD_SHADOW_ALPHA_IDLE,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the `Sprite` used for a per-card shadow at the resting state. The
|
||||||
|
/// alpha and size both use the idle tokens; `update_card_shadows_on_drag`
|
||||||
|
/// retunes them at runtime when the parent card joins / leaves the dragged
|
||||||
|
/// stack.
|
||||||
|
fn card_shadow_sprite(card_size: Vec2) -> Sprite {
|
||||||
|
let (_offset, padding, alpha) = card_shadow_params(false);
|
||||||
|
Sprite {
|
||||||
|
color: CARD_SHADOW_COLOR.with_alpha(alpha),
|
||||||
|
custom_size: Some(card_size + padding),
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the `Transform` used for a per-card shadow at the resting state.
|
||||||
|
/// Local — it is parented to the card entity, so positions are relative.
|
||||||
|
fn card_shadow_transform() -> Transform {
|
||||||
|
let (offset, _padding, _alpha) = card_shadow_params(false);
|
||||||
|
Transform::from_xyz(offset.x, offset.y, CARD_SHADOW_LOCAL_Z)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a single `CardShadow` child under the given card entity builder.
|
||||||
|
/// Extracted so `spawn_card_entity` and `update_card_entity` can share the
|
||||||
|
/// exact same shadow recipe — we never want one path to drift from the other.
|
||||||
|
fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||||
|
parent.spawn((
|
||||||
|
CardShadow,
|
||||||
|
card_shadow_sprite(card_size),
|
||||||
|
card_shadow_transform(),
|
||||||
|
Visibility::default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a `CardBackFrame` child behind a card entity to give every card a
|
||||||
|
/// thin perimeter against the dark felt, regardless of face state.
|
||||||
|
fn add_card_back_frame_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||||
|
parent.spawn((
|
||||||
|
CardBackFrame,
|
||||||
|
Sprite {
|
||||||
|
color: CARD_BACK_FRAME_COLOR,
|
||||||
|
custom_size: Some(card_size + Vec2::splat(CARD_BACK_FRAME_PADDING)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_xyz(0.0, 0.0, -0.01),
|
||||||
|
Visibility::default(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Throttle interval for resize-driven card snap work, in seconds.
|
||||||
|
///
|
||||||
|
/// `WindowResized` fires once per pixel of drag, so a fast corner-drag can
|
||||||
|
/// produce dozens of events per frame. Re-running the per-card snap logic
|
||||||
|
/// (52 cards × sprite/transform/font_size touches) for every event is the
|
||||||
|
/// dominant cost of resize lag. We coalesce pending work and apply it at most
|
||||||
|
/// once per [`RESIZE_THROTTLE_SECS`] (~20 Hz). The user still sees updates
|
||||||
|
/// during a sustained drag, and the layout always catches up to the final
|
||||||
|
/// size when the drag stops because the pending size is held until applied.
|
||||||
|
const RESIZE_THROTTLE_SECS: f32 = 0.05;
|
||||||
|
|
||||||
|
/// Holds the latest pending window size from `WindowResized` events plus a
|
||||||
|
/// timestamp for the last applied snap, so the resize-snap work can be
|
||||||
|
/// rate-limited to ~20 Hz during sustained drags.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct ResizeThrottle {
|
||||||
|
/// Latest unapplied window size from `WindowResized`. `None` when there is
|
||||||
|
/// nothing to apply.
|
||||||
|
pub pending: Option<Vec2>,
|
||||||
|
/// `Time::elapsed_secs()` value at the moment of the most recent applied
|
||||||
|
/// snap. `0.0` until the first apply.
|
||||||
|
pub last_applied_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper used by the throttled resize-snap system: returns `true` when
|
||||||
|
/// a pending resize should be flushed given the current `now_secs` and the
|
||||||
|
/// last-applied timestamp. Throttle interval is [`RESIZE_THROTTLE_SECS`].
|
||||||
|
///
|
||||||
|
/// Extracted so the rate-limit logic can be unit-tested without spinning up
|
||||||
|
/// a full Bevy app.
|
||||||
|
fn should_apply_resize(now_secs: f32, last_applied_secs: f32) -> bool {
|
||||||
|
(now_secs - last_applied_secs) >= RESIZE_THROTTLE_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders cards by reading `GameStateResource` on `StateChangedEvent`.
|
||||||
|
pub struct CardPlugin;
|
||||||
|
|
||||||
|
/// System set for everything that paints the board: card sprites, pile
|
||||||
|
/// markers, shadows, highlights, badges. Members mutate `Sprite` /
|
||||||
|
/// `Transform` on board entities and run as a deterministic chain (see the
|
||||||
|
/// registration in [`CardPlugin`]'s `build`); table-plugin marker painters
|
||||||
|
/// order themselves after this set. UI-domain systems that touch `Sprite`/
|
||||||
|
/// `Transform` on non-board entities (HUD text pulses, modal cards) declare
|
||||||
|
/// `.ambiguous_with(BoardVisuals)` instead — the entity domains are
|
||||||
|
/// disjoint by design (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct BoardVisuals;
|
||||||
|
|
||||||
|
impl Plugin for CardPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
// PostStartup ensures TablePlugin's Startup system has inserted
|
||||||
|
// LayoutResource before we try to read it.
|
||||||
|
//
|
||||||
|
// `handle_right_click` reads `ButtonInput<MouseButton>`. Under
|
||||||
|
// `MinimalPlugins` (tests) this resource is absent by default, so we
|
||||||
|
// ensure it exists here. Under `DefaultPlugins` the call is a no-op.
|
||||||
|
app.init_resource::<ButtonInput<MouseButton>>()
|
||||||
|
.init_resource::<ResizeThrottle>()
|
||||||
|
.init_resource::<CardEntityIndex>()
|
||||||
|
.add_message::<SettingsChangedEvent>()
|
||||||
|
.add_message::<CardFlippedEvent>()
|
||||||
|
.add_message::<CardFaceRevealedEvent>()
|
||||||
|
.add_systems(Startup, load_card_images)
|
||||||
|
.add_systems(
|
||||||
|
PostStartup,
|
||||||
|
(
|
||||||
|
// Fill the tableau fan before the first render so the
|
||||||
|
// cold-start deal already uses the full viewport height.
|
||||||
|
fill_tableau_fan_on_startup.before(sync_cards_startup),
|
||||||
|
sync_cards_startup,
|
||||||
|
update_stock_empty_indicator_startup,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Layout recompute (UpdateOnResize) always precedes board
|
||||||
|
// painting, and the painters run as ONE deterministic chain in
|
||||||
|
// data-flow order: layout refinement → card authority → anims →
|
||||||
|
// shadows → highlights → indicators → resize snapping → labels.
|
||||||
|
// Every painter mutates card/marker Sprite+Transform, so without
|
||||||
|
// the chain each pair is a scheduler ambiguity (#143). All
|
||||||
|
// members are cheap and mostly change-gated; sequential
|
||||||
|
// execution is not a cost that matters here.
|
||||||
|
.configure_sets(Update, LayoutSystem::UpdateOnResize.before(BoardVisuals))
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
update_tableau_fan_frac,
|
||||||
|
resync_cards_on_settings_change,
|
||||||
|
sync_cards_on_change,
|
||||||
|
start_flip_anim,
|
||||||
|
tick_flip_anim,
|
||||||
|
update_drag_shadow,
|
||||||
|
update_card_shadows_on_drag,
|
||||||
|
handle_right_click,
|
||||||
|
tick_right_click_highlights,
|
||||||
|
clear_right_click_highlights_on_state_change,
|
||||||
|
clear_right_click_highlights_on_pause,
|
||||||
|
// Reads only the anim-speed/reduce-motion flags; a frame
|
||||||
|
// of lag is invisible (#143).
|
||||||
|
tick_hint_highlight.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
update_stock_empty_indicator,
|
||||||
|
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
|
||||||
|
collect_resize_events,
|
||||||
|
snap_cards_on_window_resize,
|
||||||
|
resize_android_corner_labels,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(BoardVisuals)
|
||||||
|
.after(GameMutation),
|
||||||
|
);
|
||||||
|
|
||||||
|
app.add_systems(PostUpdate, rebuild_card_entity_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
//! Stock-pile indicators: the empty-stock recycle hint and count badge.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::KlondikePile;
|
||||||
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::table_plugin::{PILE_MARKER_DEFAULT_COLOUR, PileMarker};
|
||||||
|
use crate::ui_theme::{STOCK_BADGE_BG, STOCK_BADGE_FG, TEXT_PRIMARY, TYPE_BODY, Z_STOCK_BADGE};
|
||||||
|
|
||||||
|
/// Sprite colour applied to the stock `PileMarker` when the stock pile is empty,
|
||||||
|
/// to signal to the player that there are no more cards to draw. Pure white
|
||||||
|
/// at 0.4 alpha — a deliberate brightness-boost over the default marker so
|
||||||
|
/// the "empty" state is more visible, not less. Not derived from a palette
|
||||||
|
/// token: this is a sprite tint, not chrome colour.
|
||||||
|
const STOCK_EMPTY_DIM_COLOUR: Color = Color::srgba(1.0, 1.0, 1.0, 0.4);
|
||||||
|
|
||||||
|
/// Sprite colour applied to the stock `PileMarker` when cards remain in
|
||||||
|
/// stock. Aliased to [`PILE_MARKER_DEFAULT_COLOUR`] so it tracks the rest
|
||||||
|
/// of the engine's idle pile-marker tint automatically.
|
||||||
|
const STOCK_NORMAL_COLOUR: Color = PILE_MARKER_DEFAULT_COLOUR;
|
||||||
|
|
||||||
|
/// Shared logic for updating the stock pile marker's dim state and "↺" label.
|
||||||
|
///
|
||||||
|
/// If the stock pile is empty the marker sprite is dimmed to
|
||||||
|
/// `STOCK_EMPTY_DIM_COLOUR` and a child `Text2d` with `StockEmptyLabel` is
|
||||||
|
/// spawned (if not already present). When the stock is non-empty the marker is
|
||||||
|
/// restored to `STOCK_NORMAL_COLOUR` and any `StockEmptyLabel` children are
|
||||||
|
/// despawned.
|
||||||
|
pub(super) fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||||
|
commands: &mut Commands,
|
||||||
|
game: &GameState,
|
||||||
|
pile_markers: &mut Query<(Entity, &PileMarker, &mut Sprite), F>,
|
||||||
|
label_children: &Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
layout: &Layout,
|
||||||
|
font: Handle<Font>,
|
||||||
|
) {
|
||||||
|
let stock_empty = game.stock_cards().is_empty();
|
||||||
|
|
||||||
|
for (entity, pile_marker, mut sprite) in pile_markers.iter_mut() {
|
||||||
|
if pile_marker.0 != KlondikePile::Stock {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if stock_empty {
|
||||||
|
// Dim the marker sprite.
|
||||||
|
sprite.color = STOCK_EMPTY_DIM_COLOUR;
|
||||||
|
|
||||||
|
// Spawn the "↺" label only if one does not already exist.
|
||||||
|
let already_has_label = label_children
|
||||||
|
.iter()
|
||||||
|
.any(|(_, parent)| parent.parent() == entity);
|
||||||
|
if !already_has_label {
|
||||||
|
let font_size = layout.card_size.x * 0.4;
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
StockEmptyLabel,
|
||||||
|
Text2d::new("↺"),
|
||||||
|
TextFont {
|
||||||
|
font: font.clone(),
|
||||||
|
font_size,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Restore normal brightness.
|
||||||
|
sprite.color = STOCK_NORMAL_COLOUR;
|
||||||
|
|
||||||
|
// Despawn any existing "↺" label children.
|
||||||
|
for (label_entity, parent) in label_children.iter() {
|
||||||
|
if parent.parent() == entity {
|
||||||
|
commands.entity(label_entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs at `PostStartup` to apply the stock-empty indicator for the initial
|
||||||
|
/// game state (before any `StateChangedEvent` fires).
|
||||||
|
pub(super) fn update_stock_empty_indicator_startup(
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs each `Update` tick when a `StateChangedEvent` arrives, keeping the
|
||||||
|
/// stock pile marker dim state and "↺" label in sync with the current stock.
|
||||||
|
pub(super) fn update_stock_empty_indicator(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut pile_markers: Query<(Entity, &PileMarker, &mut Sprite)>,
|
||||||
|
label_children: Query<(Entity, &ChildOf), With<StockEmptyLabel>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
let font = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
apply_stock_empty_indicator(
|
||||||
|
&mut commands,
|
||||||
|
&game.0,
|
||||||
|
&mut pile_markers,
|
||||||
|
&label_children,
|
||||||
|
&layout.0,
|
||||||
|
font,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Stock-pile remaining-count badge
|
||||||
|
//
|
||||||
|
// Shows a small "N" chip pinned to the bottom-right corner of the stock pile so
|
||||||
|
// the player can see how many cards remain before the next recycle. The
|
||||||
|
// existing `StockEmptyLabel` (`↺` overlay) covers the empty-stock case, so
|
||||||
|
// the badge hides itself when the stock has zero cards — the two indicators
|
||||||
|
// never render at the same time.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Inset (in pixels) from the bottom-right corner of the stock pile sprite to
|
||||||
|
/// the centre of the count badge. Anchoring to the bottom-right keeps the chip
|
||||||
|
/// clear of the rank/suit pip in the card's top-left corner. Both components
|
||||||
|
/// move the centre *inward* from that corner: `x` is subtracted from the right
|
||||||
|
/// edge, `y` is added to the bottom edge. The `x` magnitude must satisfy
|
||||||
|
/// `x >= STOCK_BADGE_SIZE.x / 2` so the badge right edge stays inside the stock
|
||||||
|
/// pile and never overlaps the adjacent waste pile — critical on Android where
|
||||||
|
/// `H_GAP_DIVISOR = 32` gives an inter-pile gap of only ~4 px.
|
||||||
|
const STOCK_BADGE_INSET: Vec2 = Vec2::new(20.0, 8.0);
|
||||||
|
|
||||||
|
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||||
|
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||||
|
const STOCK_BADGE_SIZE: Vec2 = Vec2::new(34.0, 20.0);
|
||||||
|
|
||||||
|
/// Returns the count of cards currently in the stock pile.
|
||||||
|
///
|
||||||
|
/// Pure helper extracted so the count source is identical between the spawn
|
||||||
|
/// system, the update system, and the unit tests.
|
||||||
|
pub(super) fn stock_card_count(game: &GameState) -> usize {
|
||||||
|
game.stock_cards().len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the world-space `Vec3` for the centre of the stock-count badge,
|
||||||
|
/// given the current `Layout`. The badge sits at the bottom-right corner of
|
||||||
|
/// the stock pile sprite, inset by [`STOCK_BADGE_INSET`], so it stays clear of
|
||||||
|
/// the rank/suit pip in the card's top-left corner.
|
||||||
|
pub(super) fn stock_badge_translation(layout: &Layout) -> Vec3 {
|
||||||
|
// Empty layouts don't contain a Stock entry — fall back to origin so
|
||||||
|
// the badge stays in a deterministic spot until the layout is filled.
|
||||||
|
let pile_pos = layout
|
||||||
|
.pile_positions
|
||||||
|
.get(&KlondikePile::Stock)
|
||||||
|
.copied()
|
||||||
|
.unwrap_or(Vec2::ZERO);
|
||||||
|
let half = layout.card_size * 0.5;
|
||||||
|
// Anchor to the bottom-right corner, then move the centre inward.
|
||||||
|
let x = pile_pos.x + half.x - STOCK_BADGE_INSET.x;
|
||||||
|
let y = pile_pos.y - half.y + STOCK_BADGE_INSET.y;
|
||||||
|
Vec3::new(x, y, Z_STOCK_BADGE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the stock-count badge entity (background sprite + child text)
|
||||||
|
/// into the world. Called once, when the badge does not yet exist.
|
||||||
|
pub(super) fn spawn_stock_count_badge(
|
||||||
|
commands: &mut Commands,
|
||||||
|
layout: &Layout,
|
||||||
|
font: Option<&Handle<Font>>,
|
||||||
|
count: usize,
|
||||||
|
) {
|
||||||
|
let translation = stock_badge_translation(layout);
|
||||||
|
let visibility = if count == 0 {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
let text_font = TextFont {
|
||||||
|
font: font.cloned().unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
StockCountBadge,
|
||||||
|
Sprite {
|
||||||
|
color: STOCK_BADGE_BG,
|
||||||
|
custom_size: Some(STOCK_BADGE_SIZE),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
Transform::from_translation(translation),
|
||||||
|
visibility,
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
StockCountBadgeText,
|
||||||
|
Text2d::new(format!("{count}")),
|
||||||
|
text_font,
|
||||||
|
TextColor(STOCK_BADGE_FG),
|
||||||
|
// Slightly above the chip background so the digits aren't
|
||||||
|
// occluded by the sprite they sit on.
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the stock-pile remaining-count badge if it does not yet exist,
|
||||||
|
/// and otherwise updates its text and visibility in place.
|
||||||
|
///
|
||||||
|
/// Visibility rule: hidden when the stock is empty (the existing `↺`
|
||||||
|
/// `StockEmptyLabel` overlay covers that state), shown when one or more
|
||||||
|
/// cards remain.
|
||||||
|
///
|
||||||
|
/// Position is recomputed from `LayoutResource` every tick so the badge
|
||||||
|
/// follows the stock pile across `WindowResized` layout updates without
|
||||||
|
/// needing a dedicated resize handler.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_stock_count_badge(
|
||||||
|
mut commands: Commands,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
font: Option<Res<FontResource>>,
|
||||||
|
mut badges: Query<(Entity, &mut Transform, &mut Visibility), With<StockCountBadge>>,
|
||||||
|
children: Query<&Children, With<StockCountBadge>>,
|
||||||
|
mut texts: Query<&mut Text2d, With<StockCountBadgeText>>,
|
||||||
|
) {
|
||||||
|
let Some(game) = game else { return };
|
||||||
|
let Some(layout) = layout else { return };
|
||||||
|
|
||||||
|
let count = stock_card_count(&game.0);
|
||||||
|
let translation = stock_badge_translation(&layout.0);
|
||||||
|
let target_visibility = if count == 0 {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
|
||||||
|
if badges.is_empty() {
|
||||||
|
spawn_stock_count_badge(&mut commands, &layout.0, font.as_ref().map(|f| &f.0), count);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (entity, mut transform, mut visibility) in badges.iter_mut() {
|
||||||
|
transform.translation = translation;
|
||||||
|
if *visibility != target_visibility {
|
||||||
|
*visibility = target_visibility;
|
||||||
|
}
|
||||||
|
// Update the child text to reflect the latest count. The text node
|
||||||
|
// is created at spawn time, so under normal operation we always
|
||||||
|
// have exactly one child here.
|
||||||
|
if let Ok(badge_children) = children.get(entity) {
|
||||||
|
for child in badge_children.iter() {
|
||||||
|
if let Ok(mut text) = texts.get_mut(child) {
|
||||||
|
let new = format!("{count}");
|
||||||
|
if text.0 != new {
|
||||||
|
text.0 = new;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,702 @@
|
|||||||
|
//! Card asset loading and entity lifecycle: the sync systems that
|
||||||
|
//! spawn, update, and position card entities from `GameStateResource`.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use bevy::color::Color;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
|
use crate::animation_plugin::{CARD_ANIM_Z_LIFT, CardAnim, EffectiveSlideDuration};
|
||||||
|
use crate::card_animation::CardAnimation;
|
||||||
|
use crate::events::StateChangedEvent;
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::layout::{Layout, LayoutResource};
|
||||||
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
|
|
||||||
|
/// Rebuild the [`CardEntityIndex`] from the live `CardEntity` set.
|
||||||
|
///
|
||||||
|
/// Runs in `PostUpdate` so that all spawn/despawn `Commands` issued by
|
||||||
|
/// [`sync_cards_on_change`] and [`snap_cards_on_window_resize`] in `Update`
|
||||||
|
/// have been flushed at the `Update -> PostUpdate` apply-deferred boundary.
|
||||||
|
/// Rebuilding from scratch (rather than incrementally patching at every
|
||||||
|
/// spawn/despawn site — waste cards churn on every draw) keeps a single writer
|
||||||
|
/// and makes a stale entry structurally impossible.
|
||||||
|
///
|
||||||
|
/// Gated to changed frames only: `Changed<CardEntity>` fires the frame a card
|
||||||
|
/// is spawned, `RemovedComponents<CardEntity>` the frame one is despawned. The
|
||||||
|
/// `card` field is write-once (never mutated in place), so card-reposition
|
||||||
|
/// frames don't trip `Changed` and correctly skip the O(52) rebuild.
|
||||||
|
pub(super) fn rebuild_card_entity_index(
|
||||||
|
mut index: ResMut<CardEntityIndex>,
|
||||||
|
cards: Query<(Entity, &CardEntity)>,
|
||||||
|
changed: Query<(), Changed<CardEntity>>,
|
||||||
|
removed: RemovedComponents<CardEntity>,
|
||||||
|
) {
|
||||||
|
if changed.is_empty() && removed.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let map = &mut index.0;
|
||||||
|
map.clear();
|
||||||
|
for (entity, ce) in &cards {
|
||||||
|
map.insert(ce.card.clone(), entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the relative asset path for a card face PNG.
|
||||||
|
///
|
||||||
|
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
|
||||||
|
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
|
||||||
|
/// this function so the filename formula is tested in isolation from the
|
||||||
|
/// asset-loading machinery.
|
||||||
|
///
|
||||||
|
/// Note: this function verifies only the **code-side mapping**. If the PNG
|
||||||
|
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
|
||||||
|
/// diamond watermark baked in), that is an **asset content bug** and must be
|
||||||
|
/// fixed by replacing the file — no code change can correct it.
|
||||||
|
pub(super) fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
|
||||||
|
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
||||||
|
const RANK_STRS: [&str; 13] = [
|
||||||
|
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
|
||||||
|
];
|
||||||
|
let suit_idx = match suit {
|
||||||
|
Suit::Clubs => 0,
|
||||||
|
Suit::Diamonds => 1,
|
||||||
|
Suit::Hearts => 2,
|
||||||
|
Suit::Spades => 3,
|
||||||
|
};
|
||||||
|
let rank_idx = match rank {
|
||||||
|
Rank::Ace => 0,
|
||||||
|
Rank::Two => 1,
|
||||||
|
Rank::Three => 2,
|
||||||
|
Rank::Four => 3,
|
||||||
|
Rank::Five => 4,
|
||||||
|
Rank::Six => 5,
|
||||||
|
Rank::Seven => 6,
|
||||||
|
Rank::Eight => 7,
|
||||||
|
Rank::Nine => 8,
|
||||||
|
Rank::Ten => 9,
|
||||||
|
Rank::Jack => 10,
|
||||||
|
Rank::Queen => 11,
|
||||||
|
Rank::King => 12,
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"cards/faces/classic/{}{}.png",
|
||||||
|
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
|
||||||
|
/// [`CardImageSet`].
|
||||||
|
///
|
||||||
|
/// Faces: `assets/cards/faces/{RANK}{SUIT}.png` (e.g. `AC.png`, `10H.png`)
|
||||||
|
/// Backs: `assets/cards/backs/back_{0..4}.png`
|
||||||
|
///
|
||||||
|
/// Under `MinimalPlugins` (tests) `AssetServer` is absent, so the system
|
||||||
|
/// returns without inserting `CardImageSet` and the plugin falls back to
|
||||||
|
/// solid-colour sprites.
|
||||||
|
pub(super) fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Commands) {
|
||||||
|
let Some(asset_server) = asset_server else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// faces[suit_index(s)][rank_index(r)] — see the canonical helpers in
|
||||||
|
// card_plugin::mod; building from SUITS/RANKS order matches them.
|
||||||
|
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
||||||
|
std::array::from_fn(|ri| {
|
||||||
|
asset_server.load(card_face_asset_path(Rank::RANKS[ri], Suit::SUITS[si]))
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let backs =
|
||||||
|
std::array::from_fn(|i| asset_server.load(format!("cards/backs/classic/back_{i}.png")));
|
||||||
|
commands.insert_resource(CardImageSet {
|
||||||
|
faces,
|
||||||
|
backs,
|
||||||
|
// Populated by the theme plugin once a `CardTheme` finishes loading.
|
||||||
|
// Until then the legacy back fallback (`backs[selected_card_back]`)
|
||||||
|
// is used.
|
||||||
|
theme_back: None,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the [`Sprite`] for a card, using PNG artwork when [`CardImageSet`] is
|
||||||
|
/// available and falling back to a solid-colour sprite in tests.
|
||||||
|
pub(super) fn card_sprite(
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
card_size: Vec2,
|
||||||
|
back_colour: Color,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
) -> Sprite {
|
||||||
|
if let Some(set) = card_images {
|
||||||
|
let image = if face_up {
|
||||||
|
let suit_idx = suit_index(card.suit());
|
||||||
|
let rank_idx = rank_index(card.rank());
|
||||||
|
set.faces[suit_idx][rank_idx].clone()
|
||||||
|
} else if let Some(theme_back) = &set.theme_back {
|
||||||
|
// Active theme provides its own back — always wins over the
|
||||||
|
// legacy `selected_card_back` picker, so a theme switch swaps
|
||||||
|
// faces *and* the back. The picker is treated as informational
|
||||||
|
// only while a theme back is active (see settings_plugin).
|
||||||
|
theme_back.clone()
|
||||||
|
} else {
|
||||||
|
let idx = selected_back.min(set.backs.len() - 1);
|
||||||
|
set.backs[idx].clone()
|
||||||
|
};
|
||||||
|
Sprite {
|
||||||
|
image,
|
||||||
|
color: Color::WHITE,
|
||||||
|
custom_size: Some(card_size),
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Terminal aesthetic: face background is uniformly CARD_FACE_COLOUR
|
||||||
|
// regardless of colour-blind mode (CBM differentiation now lives in
|
||||||
|
// the suit glyph colour, applied by `text_colour`, not the face
|
||||||
|
// background). Pre-Terminal this branch dispatched through a
|
||||||
|
// separate `face_colour(card, color_blind)` helper.
|
||||||
|
let body_colour = if face_up {
|
||||||
|
CARD_FACE_COLOUR
|
||||||
|
} else {
|
||||||
|
back_colour
|
||||||
|
};
|
||||||
|
Sprite {
|
||||||
|
color: body_colour,
|
||||||
|
custom_size: Some(card_size),
|
||||||
|
..default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// When card-back selection changes in Settings, re-render all cards so the
|
||||||
|
/// new back colour is applied immediately (without waiting for a state change).
|
||||||
|
pub(super) fn resync_cards_on_settings_change(
|
||||||
|
mut setting_events: MessageReader<SettingsChangedEvent>,
|
||||||
|
mut state_events: MessageWriter<StateChangedEvent>,
|
||||||
|
) {
|
||||||
|
if setting_events.read().next().is_some() {
|
||||||
|
state_events.write(StateChangedEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
||||||
|
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
||||||
|
/// have already completed.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_cards_startup(
|
||||||
|
commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
entities: Query<CardSyncData>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if let Some(layout) = layout {
|
||||||
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
|
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
|
let back_colour = card_back_colour(selected_back);
|
||||||
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
|
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||||
|
sync_cards(
|
||||||
|
commands,
|
||||||
|
&game.0,
|
||||||
|
&layout.0,
|
||||||
|
slide_secs,
|
||||||
|
back_colour,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
&entities,
|
||||||
|
card_images.as_deref(),
|
||||||
|
selected_back,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_cards_on_change(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
commands: Commands,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
slide_dur: Option<Res<EffectiveSlideDuration>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
entities: Query<CardSyncData>,
|
||||||
|
card_images: Option<Res<CardImageSet>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if events.read().next().is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Some(layout) = layout {
|
||||||
|
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||||
|
let selected_back = settings.as_ref().map_or(0, |s| s.0.selected_card_back);
|
||||||
|
let back_colour = card_back_colour(selected_back);
|
||||||
|
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||||
|
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||||
|
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||||
|
sync_cards(
|
||||||
|
commands,
|
||||||
|
&game.0,
|
||||||
|
&layout.0,
|
||||||
|
slide_secs,
|
||||||
|
back_colour,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
&entities,
|
||||||
|
card_images.as_deref(),
|
||||||
|
selected_back,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn sync_cards(
|
||||||
|
mut commands: Commands,
|
||||||
|
game: &GameState,
|
||||||
|
layout: &Layout,
|
||||||
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
entities: &Query<CardSyncData>,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
let positions = card_positions(game, layout);
|
||||||
|
|
||||||
|
// The waste buffer card exists only to keep its entity alive while the new
|
||||||
|
// top card's slide animation plays — it must never be visible to the player.
|
||||||
|
// Without this, the buffer sits at waste_base uncovered during the animation
|
||||||
|
// and its rank/suit peek behind the incoming card.
|
||||||
|
let waste_buffer_id: Option<Card> = {
|
||||||
|
let visible = match game.draw_mode() {
|
||||||
|
DrawStockConfig::DrawOne => 1_usize,
|
||||||
|
DrawStockConfig::DrawThree => 3_usize,
|
||||||
|
};
|
||||||
|
let waste_cards = game.waste_cards();
|
||||||
|
(waste_cards.len() > visible)
|
||||||
|
.then_some(waste_cards)
|
||||||
|
.and_then(|w| w.get(w.len().saturating_sub(visible + 1)).cloned())
|
||||||
|
.map(|(c, _face_up)| c)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map Card -> (Entity, current_translation, anim_end) for in-place
|
||||||
|
// updates. `anim_end` is `Some(end_xy)` when a curve-based `CardAnimation`
|
||||||
|
// is currently driving the card (e.g. a drag-rejection return tween).
|
||||||
|
//
|
||||||
|
// In the position loop below we compare `anim_end` against the new game-
|
||||||
|
// state target position to decide whether to honour or cancel the tween:
|
||||||
|
// • end ≈ target → animation is still heading to the right place; let
|
||||||
|
// it finish (skip the snap/slide path).
|
||||||
|
// • end ≠ target → the game state has changed (e.g. a new game started
|
||||||
|
// while the win-cascade was mid-flight); cancel the
|
||||||
|
// stale `CardAnimation` and apply the new position.
|
||||||
|
let mut existing: HashMap<Card, (Entity, Vec3, Option<Vec2>, Option<CardChildrenKey>)> =
|
||||||
|
HashMap::new();
|
||||||
|
for (entity, marker, transform, anim, children_key) in entities.iter() {
|
||||||
|
existing.insert(
|
||||||
|
marker.card.clone(),
|
||||||
|
(
|
||||||
|
entity,
|
||||||
|
transform.translation,
|
||||||
|
anim.map(|a| a.end),
|
||||||
|
children_key.copied(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let live_ids: HashSet<Card> = positions.iter().map(|(c, _, _)| c.0.clone()).collect();
|
||||||
|
|
||||||
|
// Despawn any entity whose card is no longer tracked.
|
||||||
|
for (card, (entity, _, _, _)) in &existing {
|
||||||
|
if !live_ids.contains(card) {
|
||||||
|
commands.entity(*entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each card in the current state: spawn or update its entity, then
|
||||||
|
// apply visibility. The waste buffer card is hidden so it cannot peek
|
||||||
|
// behind the incoming top card during the draw slide animation.
|
||||||
|
for ((card, face_up), position, z) in positions {
|
||||||
|
let entity = match existing.get(&card) {
|
||||||
|
Some(&(entity, cur, anim_end, children_key)) => {
|
||||||
|
// If a CardAnimation is in flight, check whether its destination
|
||||||
|
// still matches the game-state target. If the game moved the card
|
||||||
|
// elsewhere (e.g. new game started during a win-cascade scatter),
|
||||||
|
// cancel the stale tween so the card snaps/slides to its new home.
|
||||||
|
let has_anim = match anim_end {
|
||||||
|
Some(end_xy) if (end_xy - position).length() > 2.0 => {
|
||||||
|
commands.entity(entity).remove::<CardAnimation>();
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Some(_) => true,
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
update_card_entity(
|
||||||
|
&mut commands,
|
||||||
|
entity,
|
||||||
|
&card,
|
||||||
|
face_up,
|
||||||
|
position,
|
||||||
|
z,
|
||||||
|
layout,
|
||||||
|
slide_secs,
|
||||||
|
back_colour,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
cur,
|
||||||
|
has_anim,
|
||||||
|
children_key,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
entity
|
||||||
|
}
|
||||||
|
None => spawn_card_entity(
|
||||||
|
&mut commands,
|
||||||
|
&card,
|
||||||
|
face_up,
|
||||||
|
position,
|
||||||
|
z,
|
||||||
|
layout,
|
||||||
|
back_colour,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
font_handle,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
let visibility = if waste_buffer_id.as_ref() == Some(&card) {
|
||||||
|
Visibility::Hidden
|
||||||
|
} else {
|
||||||
|
Visibility::Inherited
|
||||||
|
};
|
||||||
|
commands.entity(entity).insert(visibility);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns an ordered vec of ((card, face_up), position, z) for every card in the game.
|
||||||
|
pub(super) fn card_positions(game: &GameState, layout: &Layout) -> Vec<((Card, bool), Vec2, f32)> {
|
||||||
|
let mut out: Vec<((Card, bool), Vec2, f32)> = Vec::with_capacity(52);
|
||||||
|
let piles = [
|
||||||
|
(KlondikePile::Stock, true),
|
||||||
|
(KlondikePile::Stock, false),
|
||||||
|
(KlondikePile::Foundation(Foundation::Foundation1), false),
|
||||||
|
(KlondikePile::Foundation(Foundation::Foundation2), false),
|
||||||
|
(KlondikePile::Foundation(Foundation::Foundation3), false),
|
||||||
|
(KlondikePile::Foundation(Foundation::Foundation4), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau1), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau2), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau3), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau4), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau5), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau6), false),
|
||||||
|
(KlondikePile::Tableau(Tableau::Tableau7), false),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Draw-Three waste fan step, proportional to the column spacing so it scales
|
||||||
|
// with the platform's H_GAP_DIVISOR. Shared with input_plugin's hit-test via
|
||||||
|
// `waste_fan_step` so the two never drift (a drift puts the top fanned card's
|
||||||
|
// click target on the card beneath it).
|
||||||
|
let waste_fan_step = waste_fan_step(layout);
|
||||||
|
|
||||||
|
for (pile_type, is_stock_area) in piles {
|
||||||
|
let Some(mut base) = layout.pile_positions.get(&pile_type).copied() else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if matches!(pile_type, KlondikePile::Stock) && is_stock_area {
|
||||||
|
base.x -= tableau_col_step(layout);
|
||||||
|
}
|
||||||
|
let is_tableau = matches!(pile_type, KlondikePile::Tableau(_));
|
||||||
|
let is_waste = matches!(pile_type, KlondikePile::Stock) && !is_stock_area;
|
||||||
|
let cards = if matches!(pile_type, KlondikePile::Stock) {
|
||||||
|
if is_stock_area {
|
||||||
|
game.stock_cards()
|
||||||
|
} else {
|
||||||
|
game.waste_cards()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
game.pile(pile_type)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Tableau uses a two-speed fan: face-down cards are packed tighter
|
||||||
|
// than face-up cards so the visible (playable) portion stands out.
|
||||||
|
// Non-tableau piles stack with a negligible offset.
|
||||||
|
//
|
||||||
|
// Waste pile: only the top N cards are rendered to prevent bleed-through
|
||||||
|
// while new cards animate in from the stock. Draw-One shows 1; Draw-Three
|
||||||
|
// shows up to 3 fanned in X (matching the standard Klondike presentation).
|
||||||
|
let render_start = if is_waste {
|
||||||
|
let visible = match game.draw_mode() {
|
||||||
|
DrawStockConfig::DrawOne => 1_usize,
|
||||||
|
DrawStockConfig::DrawThree => 3_usize,
|
||||||
|
};
|
||||||
|
// Render one extra card so that the card sliding off the waste
|
||||||
|
// during a draw animation is still present in the world at z=0
|
||||||
|
// (hidden under the stack) rather than vanishing mid-tween.
|
||||||
|
cards.len().saturating_sub(visible + 1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut y_offset = 0.0_f32;
|
||||||
|
let rendered_len = cards[render_start..].len();
|
||||||
|
for (slot, (card, face_up)) in cards[render_start..].iter().enumerate() {
|
||||||
|
let x_offset = if is_waste && matches!(game.draw_mode(), DrawStockConfig::DrawThree) {
|
||||||
|
// When len > visible, slot 0 is a hidden buffer card kept at
|
||||||
|
// x=0 to prevent a flash during the draw tween. When len ≤
|
||||||
|
// visible (small pile), every card is visible and should fan
|
||||||
|
// normally — no card is hidden, so the shift is 0.
|
||||||
|
let visible = 3_usize;
|
||||||
|
let hidden = rendered_len.saturating_sub(visible);
|
||||||
|
slot.saturating_sub(hidden) as f32 * waste_fan_step
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let pos = Vec2::new(base.x + x_offset, base.y + y_offset);
|
||||||
|
let z = 1.0 + (slot as f32) * STACK_FAN_FRAC;
|
||||||
|
out.push(((card.clone(), *face_up), pos, z));
|
||||||
|
if is_tableau {
|
||||||
|
let step = if *face_up {
|
||||||
|
layout.tableau_fan_frac
|
||||||
|
} else {
|
||||||
|
layout.tableau_facedown_fan_frac
|
||||||
|
};
|
||||||
|
y_offset -= layout.card_size.y * step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn all_cards(game: &GameState) -> Vec<(Card, bool)> {
|
||||||
|
let mut cards: Vec<(Card, bool)> = Vec::with_capacity(52);
|
||||||
|
cards.extend(game.stock_cards());
|
||||||
|
cards.extend(game.waste_cards());
|
||||||
|
for foundation in solitaire_core::FOUNDATIONS {
|
||||||
|
cards.extend(game.pile(KlondikePile::Foundation(foundation)));
|
||||||
|
}
|
||||||
|
for tableau in solitaire_core::TABLEAUS {
|
||||||
|
cards.extend(game.pile(KlondikePile::Tableau(tableau)));
|
||||||
|
}
|
||||||
|
cards
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn spawn_card_entity(
|
||||||
|
commands: &mut Commands,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
pos: Vec2,
|
||||||
|
z: f32,
|
||||||
|
layout: &Layout,
|
||||||
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) -> Entity {
|
||||||
|
let sprite = card_sprite(
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
back_colour,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut entity = commands.spawn((
|
||||||
|
CardEntity { card: card.clone() },
|
||||||
|
sprite,
|
||||||
|
Transform::from_xyz(pos.x, pos.y, z),
|
||||||
|
Visibility::default(),
|
||||||
|
));
|
||||||
|
let entity_id = entity.id();
|
||||||
|
// Every card gets a subtle drop-shadow child so the play surface reads
|
||||||
|
// as physical instead of flat. Spawned in idle state; the drag-tracking
|
||||||
|
// system retunes its offset / alpha when this card joins the dragged
|
||||||
|
// stack.
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_card_shadow_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
// Every card gets a thin border frame so it reads as a distinct
|
||||||
|
// rectangle against the dark felt, regardless of face state.
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
// When PNG faces are loaded the rank/suit are baked into the image.
|
||||||
|
// Only spawn the Text2d overlay in the solid-colour fallback (tests).
|
||||||
|
// On Android we additionally spawn a large-print corner label even in
|
||||||
|
// image mode so the rank/suit are legible at phone scale.
|
||||||
|
if card_images.is_none() {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_for(card)),
|
||||||
|
TextFont {
|
||||||
|
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||||
|
label_visibility(face_up),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||||
|
entity.with_children(|b| {
|
||||||
|
add_android_corner_label(
|
||||||
|
b,
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Record the appearance signature so subsequent `update_card_entity` calls
|
||||||
|
// can skip rebuilding these children until one of the inputs changes.
|
||||||
|
entity.insert(CardChildrenKey {
|
||||||
|
face_up,
|
||||||
|
card_size: layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
});
|
||||||
|
entity_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_card_entity(
|
||||||
|
commands: &mut Commands,
|
||||||
|
entity: Entity,
|
||||||
|
card: &Card,
|
||||||
|
face_up: bool,
|
||||||
|
pos: Vec2,
|
||||||
|
z: f32,
|
||||||
|
layout: &Layout,
|
||||||
|
slide_secs: f32,
|
||||||
|
back_colour: Color,
|
||||||
|
color_blind: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
cur: Vec3,
|
||||||
|
has_card_animation: bool,
|
||||||
|
existing_children_key: Option<CardChildrenKey>,
|
||||||
|
card_images: Option<&CardImageSet>,
|
||||||
|
selected_back: usize,
|
||||||
|
font_handle: Option<&Handle<Font>>,
|
||||||
|
) {
|
||||||
|
let target = Vec3::new(pos.x, pos.y, z);
|
||||||
|
|
||||||
|
// Always refresh the visual appearance.
|
||||||
|
commands.entity(entity).insert(card_sprite(
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
back_colour,
|
||||||
|
card_images,
|
||||||
|
selected_back,
|
||||||
|
));
|
||||||
|
|
||||||
|
// Skip the snap/slide path entirely when a curve-based `CardAnimation`
|
||||||
|
// is driving this card (e.g. the drag-rejection return tween). Writing
|
||||||
|
// `Transform` here would race that animation each frame and cause a
|
||||||
|
// visible jump. The animation system snaps the final position itself
|
||||||
|
// when it completes.
|
||||||
|
if !has_card_animation {
|
||||||
|
// Slide to the new position when it differs meaningfully; snap otherwise.
|
||||||
|
if (cur.truncate() - target.truncate()).length() > 1.0 && slide_secs > 0.0 {
|
||||||
|
// Lift the card immediately on the first frame of the animation so
|
||||||
|
// it never appears behind a card that is already resting at the
|
||||||
|
// destination slot. `advance_card_anims` will maintain this lift
|
||||||
|
// throughout the tween and snap to `target` (without lift) on
|
||||||
|
// completion.
|
||||||
|
let start = Vec3::new(cur.x, cur.y, z + CARD_ANIM_Z_LIFT);
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.insert(Transform::from_translation(start))
|
||||||
|
.insert(CardAnim {
|
||||||
|
start,
|
||||||
|
target,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: slide_secs,
|
||||||
|
delay: 0.0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<CardAnim>()
|
||||||
|
.insert(Transform::from_xyz(pos.x, pos.y, z));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild the card's child visuals (drop-shadow, border frame, and the
|
||||||
|
// rank/suit label / large-print corner overlay) only when an input that
|
||||||
|
// affects them actually changed. The child set depends solely on
|
||||||
|
// `CardChildrenKey`; the face/back image is carried by the always-refreshed
|
||||||
|
// `Sprite` above, so theme/card-back swaps need no child rebuild. Skipping
|
||||||
|
// this on a position-only move avoids despawning and respawning the child
|
||||||
|
// entities (incl. a `Text2d` glyph re-layout) for all 52 cards on every
|
||||||
|
// `StateChangedEvent` — the spike that stuttered the slide animation on
|
||||||
|
// high-resolution devices.
|
||||||
|
let new_children_key = CardChildrenKey {
|
||||||
|
face_up,
|
||||||
|
card_size: layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
};
|
||||||
|
if existing_children_key != Some(new_children_key) {
|
||||||
|
commands.entity(entity).despawn_related::<Children>();
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_card_shadow_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_card_back_frame_child(b, layout.card_size);
|
||||||
|
});
|
||||||
|
if card_images.is_none() {
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
CardLabel,
|
||||||
|
Text2d::new(label_for(card)),
|
||||||
|
TextFont {
|
||||||
|
font_size: layout.card_size.x * FONT_SIZE_FRAC,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||||
|
Transform::from_xyz(0.0, 0.0, 0.01),
|
||||||
|
label_visibility(face_up),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if USE_TOUCH_UI_LAYOUT && card_images.is_some() {
|
||||||
|
commands.entity(entity).with_children(|b| {
|
||||||
|
add_android_corner_label(
|
||||||
|
b,
|
||||||
|
card,
|
||||||
|
face_up,
|
||||||
|
layout.card_size,
|
||||||
|
color_blind,
|
||||||
|
high_contrast,
|
||||||
|
font_handle,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
commands.entity(entity).insert(new_children_key);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -11,8 +11,7 @@ use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
|
|||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
/// Minimum player level required to start a Challenge run.
|
/// Minimum player level required to start a Challenge run.
|
||||||
@@ -38,8 +37,28 @@ impl Plugin for ChallengePlugin {
|
|||||||
.add_message::<StartChallengeRequestEvent>()
|
.add_message::<StartChallengeRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
|
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
|
||||||
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
|
// Ordering comes from the mode-completion chain configured in
|
||||||
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
|
// progress_plugin; the toast it emits joins the writer club
|
||||||
|
// drained by the toast pipeline this frame (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
advance_on_challenge_win
|
||||||
|
.in_set(crate::progress_plugin::ChallengeCompletion)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// ModeStart places it between the stats abandon-recorder and
|
||||||
|
// GameMutation; it shares the new-game request/toast streams
|
||||||
|
// with the other writers (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
handle_start_challenge_request
|
||||||
|
.in_set(crate::game_plugin::ModeStart)
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Hold-`/` hotkey cheat sheet (Phase J).
|
||||||
|
//!
|
||||||
|
//! While `/` is held on a keyboard platform, a lightweight overlay
|
||||||
|
//! lists every binding from [`crate::hotkeys::HOTKEYS`] — the same
|
||||||
|
//! table the onboarding slide teaches from, so the two can never
|
||||||
|
//! disagree. Releasing the key hides it instantly; it never captures
|
||||||
|
//! input, never pauses the game, and never spawns while a modal owns
|
||||||
|
//! the screen (which also keeps it out of the seed-entry text field's
|
||||||
|
//! way).
|
||||||
|
//!
|
||||||
|
//! Not a `spawn_modal` modal on purpose: modals are sticky and guarded;
|
||||||
|
//! this is a momentary reference card, closer to a tooltip than a
|
||||||
|
//! dialog.
|
||||||
|
|
||||||
|
use bevy::input::ButtonInput;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::hotkeys::HOTKEYS;
|
||||||
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, HighContrastBorder, RADIUS_MD, TEXT_PRIMARY,
|
||||||
|
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||||
|
Z_TOOLTIP,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Marker on the cheat-sheet overlay root.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CheatSheetOverlay;
|
||||||
|
|
||||||
|
/// Registers the hold-`/` driver. Inert on touch-first builds.
|
||||||
|
pub struct CheatSheetPlugin;
|
||||||
|
|
||||||
|
impl Plugin for CheatSheetPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>()
|
||||||
|
.add_systems(Update, drive_cheat_sheet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the overlay while `/` is held (and no modal owns the screen);
|
||||||
|
/// hides it the frame the key releases.
|
||||||
|
fn drive_cheat_sheet(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
existing: Query<Entity, With<CheatSheetOverlay>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let held = keys.pressed(KeyCode::Slash);
|
||||||
|
if held && existing.is_empty() && scrims.is_empty() {
|
||||||
|
spawn_cheat_sheet(&mut commands, font_res.as_deref());
|
||||||
|
} else if !held {
|
||||||
|
for entity in &existing {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_cheat_sheet(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_header = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_keys = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_desc = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
CheatSheetOverlay,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: Val::Px(16.0),
|
||||||
|
top: Val::Percent(8.0),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
padding: UiRect::all(VAL_SPACE_4),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
max_height: Val::Percent(84.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
BorderColor::all(BORDER_STRONG),
|
||||||
|
HighContrastBorder::with_default(BORDER_STRONG),
|
||||||
|
GlobalZIndex(Z_TOOLTIP),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
panel.spawn((
|
||||||
|
Text::new("Keyboard shortcuts"),
|
||||||
|
font_header.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
Node {
|
||||||
|
margin: UiRect::bottom(VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
for row in HOTKEYS {
|
||||||
|
panel
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|line| {
|
||||||
|
line.spawn((
|
||||||
|
Text::new(row.keys),
|
||||||
|
font_keys.clone(),
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
Node {
|
||||||
|
min_width: Val::Px(110.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
line.spawn((
|
||||||
|
Text::new(row.description),
|
||||||
|
font_desc.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(CheatSheetPlugin);
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&CheatSheetOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn holding_slash_shows_and_release_hides() {
|
||||||
|
let mut app = app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Slash);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(overlay_count(&mut app), 1, "held / must show the sheet");
|
||||||
|
// Still held on later frames: exactly one overlay, no stacking.
|
||||||
|
app.update();
|
||||||
|
assert_eq!(overlay_count(&mut app), 1);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.release(KeyCode::Slash);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(overlay_count(&mut app), 0, "release must hide the sheet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suppressed_while_a_modal_is_open() {
|
||||||
|
let mut app = app();
|
||||||
|
app.world_mut().spawn(ModalScrim);
|
||||||
|
app.update();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Slash);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
overlay_count(&mut app),
|
||||||
|
0,
|
||||||
|
"the sheet must not spawn over a modal (or into a text field)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
//! Contextual one-time tips (Phase I of the 2026-07 UI redesign).
|
||||||
|
//!
|
||||||
|
//! Everything the first-run onboarding doesn't cover is invisible until
|
||||||
|
//! stumbled upon: hint cycling, the radial quick-action menu. These
|
||||||
|
//! tips fire **from the situation, not a tour** — each exactly once,
|
||||||
|
//! recorded in `Settings` like `shown_achievement_onboarding`:
|
||||||
|
//!
|
||||||
|
//! - **Stall tip** — after [`STALL_TIP_SECS`] with no board change in an
|
||||||
|
//! active game, an info toast points at Hint. A player staring at a
|
||||||
|
//! stuck board is the one moment the tip is welcome.
|
||||||
|
//! - **Radial tip** — once a game reaches [`RADIAL_TIP_MIN_MOVES`] moves
|
||||||
|
//! (an engaged player) and the radial menu has never been opened, an
|
||||||
|
//! info toast teaches the long-press / right-click gesture. Opening
|
||||||
|
//! the radial organically marks the tip as unnecessary — it is never
|
||||||
|
//! shown to someone who already knows.
|
||||||
|
//!
|
||||||
|
//! Tips render through the queued [`InfoToastEvent`] path, so they share
|
||||||
|
//! the unified toast stack and never interrupt play.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use solitaire_data::save_settings_to;
|
||||||
|
|
||||||
|
use crate::events::{InfoToastEvent, StateChangedEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||||
|
use crate::radial_menu::RightClickRadialState;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
|
||||||
|
/// Seconds without any board change before the stall tip fires.
|
||||||
|
/// Long enough that normal thinking never triggers it; short enough to
|
||||||
|
/// reach a genuinely stuck player before they quit.
|
||||||
|
const STALL_TIP_SECS: f32 = 45.0;
|
||||||
|
|
||||||
|
/// Move count at which an engaged player earns the radial-menu teach.
|
||||||
|
const RADIAL_TIP_MIN_MOVES: u32 = 15;
|
||||||
|
|
||||||
|
/// Stall-tip copy per platform input vocabulary.
|
||||||
|
const STALL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
"Stuck? Press H for a hint."
|
||||||
|
} else {
|
||||||
|
"Stuck? Tap Hint in the bottom bar for a suggested move."
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Radial-tip copy per platform input vocabulary.
|
||||||
|
const RADIAL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
"Tip: right-click a card for quick actions."
|
||||||
|
} else {
|
||||||
|
"Tip: long-press a card for quick actions."
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Seconds of board inactivity, frozen while paused / a modal is open,
|
||||||
|
/// reset by every [`StateChangedEvent`].
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct StallClock(pub f32);
|
||||||
|
|
||||||
|
/// Registers the stall clock and the two tip triggers.
|
||||||
|
pub struct ContextualTipsPlugin;
|
||||||
|
|
||||||
|
impl Plugin for ContextualTipsPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<StallClock>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
tick_stall_clock,
|
||||||
|
fire_stall_hint_tip
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
observe_radial_menu_use,
|
||||||
|
fire_radial_menu_tip
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances the stall clock; resets on any board change; freezes while
|
||||||
|
/// paused, while a modal owns the screen, or when the game is over.
|
||||||
|
fn tick_stall_clock(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut state_events: MessageReader<StateChangedEvent>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut clock: ResMut<StallClock>,
|
||||||
|
) {
|
||||||
|
if state_events.read().next().is_some() {
|
||||||
|
clock.0 = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if paused.is_some_and(|p| p.0)
|
||||||
|
|| !scrims.is_empty()
|
||||||
|
|| game.as_ref().is_none_or(|g| g.0.is_won())
|
||||||
|
{
|
||||||
|
// Frozen, not reset: backgrounding into a menu mid-stall
|
||||||
|
// shouldn't restart the wait.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clock.0 += time.delta_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires the one-shot stall tip once the clock passes the threshold in
|
||||||
|
/// a game the player has actually started (at least one move).
|
||||||
|
fn fire_stall_hint_tip(
|
||||||
|
clock: Res<StallClock>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let Some(s) = settings.as_mut() else { return };
|
||||||
|
if s.0.shown_stall_hint_tip || clock.0 < STALL_TIP_SECS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if game
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|g| g.0.move_count() == 0 || g.0.is_won())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.write(InfoToastEvent(STALL_TIP.to_string()));
|
||||||
|
s.0.shown_stall_hint_tip = true;
|
||||||
|
persist(storage_path.as_deref(), &s.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A player who opens the radial menu on their own doesn't need the
|
||||||
|
/// teach — mark the tip done silently.
|
||||||
|
fn observe_radial_menu_use(
|
||||||
|
radial: Option<Res<RightClickRadialState>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
) {
|
||||||
|
let Some(radial) = radial else { return };
|
||||||
|
if !radial.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(s) = settings.as_mut() else { return };
|
||||||
|
if s.0.shown_radial_menu_tip {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.0.shown_radial_menu_tip = true;
|
||||||
|
persist(storage_path.as_deref(), &s.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires the one-shot radial teach for an engaged player who has never
|
||||||
|
/// opened the menu themselves.
|
||||||
|
fn fire_radial_menu_tip(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let Some(s) = settings.as_mut() else { return };
|
||||||
|
if s.0.shown_radial_menu_tip {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if game
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|g| g.0.move_count() < RADIAL_TIP_MIN_MOVES || g.0.is_won())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.write(InfoToastEvent(RADIAL_TIP.to_string()));
|
||||||
|
s.0.shown_radial_menu_tip = true;
|
||||||
|
persist(storage_path.as_deref(), &s.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist(storage_path: Option<&SettingsStoragePath>, settings: &solitaire_data::Settings) {
|
||||||
|
if let Some(p) = storage_path
|
||||||
|
&& let Some(path) = p.0.as_deref()
|
||||||
|
&& let Err(e) = save_settings_to(path, settings)
|
||||||
|
{
|
||||||
|
warn!("contextual tips: failed to persist tip flag: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
fn app_with(settings: Settings, game: GameState) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(ContextualTipsPlugin);
|
||||||
|
app.insert_resource(SettingsResource(settings));
|
||||||
|
app.insert_resource(GameStateResource(game));
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info_toast_count(app: &App) -> usize {
|
||||||
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
cursor.read(events).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn started_game() -> GameState {
|
||||||
|
let mut game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||||
|
game.draw().expect("draw from fresh deal");
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_tip_fires_once_past_threshold() {
|
||||||
|
let mut app = app_with(Settings::default(), started_game());
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(info_toast_count(&app), 1, "stall tip must fire");
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_stall_hint_tip,
|
||||||
|
"the tip flag must set so it never repeats"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 30.0;
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<InfoToastEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(info_toast_count(&app), 0, "the tip is one-shot");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_tip_needs_a_started_unwon_game() {
|
||||||
|
// Untouched deal: staring at the launch screen is not a stall.
|
||||||
|
let mut app = app_with(
|
||||||
|
Settings::default(),
|
||||||
|
GameState::new(7, DrawStockConfig::DrawOne),
|
||||||
|
);
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(info_toast_count(&app), 0, "no tip on an untouched deal");
|
||||||
|
|
||||||
|
// Won game: nothing to hint at.
|
||||||
|
let mut won = started_game();
|
||||||
|
won.set_test_won(true);
|
||||||
|
let mut app = app_with(Settings::default(), won);
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(info_toast_count(&app), 0, "no tip on a won game");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_clock_resets_on_state_change() {
|
||||||
|
let mut app = app_with(Settings::default(), started_game());
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = 30.0;
|
||||||
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<StallClock>().0,
|
||||||
|
0.0,
|
||||||
|
"any board change must reset the stall clock"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_tip_fires_for_engaged_player() {
|
||||||
|
let mut game = started_game();
|
||||||
|
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
|
||||||
|
let mut app = app_with(Settings::default(), game);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
info_toast_count(&app),
|
||||||
|
1,
|
||||||
|
"radial tip must fire at the move threshold"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_radial_menu_tip
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_tip_suppressed_after_organic_use() {
|
||||||
|
let mut game = started_game();
|
||||||
|
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
|
||||||
|
let mut app = app_with(
|
||||||
|
Settings {
|
||||||
|
shown_radial_menu_tip: true,
|
||||||
|
..Settings::default()
|
||||||
|
},
|
||||||
|
game,
|
||||||
|
);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
info_toast_count(&app),
|
||||||
|
0,
|
||||||
|
"a player who used the radial menu never sees the teach"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,17 +14,19 @@ use crate::platform::{
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
|
||||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
|
||||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncProvider,
|
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
|
||||||
TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin,
|
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
|
||||||
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin,
|
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
|
||||||
|
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::{
|
use crate::{
|
||||||
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
AnalyticsPlugin, AudioPlugin, AvatarPlugin, LeaderboardPlugin, SyncPlugin, SyncSetupPlugin,
|
||||||
|
ThemeStorePlugin,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Groups all Ferrous Solitaire gameplay plugins.
|
/// Groups all Ferrous Solitaire gameplay plugins.
|
||||||
@@ -92,6 +94,7 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(FeedbackAnimPlugin)
|
.add_plugins(FeedbackAnimPlugin)
|
||||||
.add_plugins(CardAnimationPlugin)
|
.add_plugins(CardAnimationPlugin)
|
||||||
.add_plugins(AutoCompletePlugin)
|
.add_plugins(AutoCompletePlugin)
|
||||||
|
.add_plugins(SolutionPlaybackPlugin)
|
||||||
.add_plugins(ReplayPlaybackPlugin)
|
.add_plugins(ReplayPlaybackPlugin)
|
||||||
.add_plugins(ReplayOverlayPlugin)
|
.add_plugins(ReplayOverlayPlugin)
|
||||||
.add_plugins(StatsPlugin::default())
|
.add_plugins(StatsPlugin::default())
|
||||||
@@ -108,9 +111,13 @@ 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)
|
||||||
|
.add_plugins(WhatsNewPlugin)
|
||||||
|
.add_plugins(ContextualTipsPlugin)
|
||||||
|
.add_plugins(CheatSheetPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.add_plugins(UiFocusPlugin)
|
.add_plugins(UiFocusPlugin)
|
||||||
@@ -126,6 +133,7 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(AudioPlugin)
|
.add_plugins(AudioPlugin)
|
||||||
.add_plugins(SyncPlugin::new(sync_provider))
|
.add_plugins(SyncPlugin::new(sync_provider))
|
||||||
.add_plugins(SyncSetupPlugin)
|
.add_plugins(SyncSetupPlugin)
|
||||||
|
.add_plugins(ThemeStorePlugin)
|
||||||
.add_plugins(AnalyticsPlugin)
|
.add_plugins(AnalyticsPlugin)
|
||||||
.add_plugins(LeaderboardPlugin);
|
.add_plugins(LeaderboardPlugin);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,8 +35,8 @@
|
|||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
use bevy::window::{CursorIcon, PrimaryWindow, SystemCursorIcon};
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
use crate::card_plugin::RightClickHighlight;
|
use crate::card_plugin::RightClickHighlight;
|
||||||
use crate::layout::{Layout, LayoutResource};
|
use crate::layout::{Layout, LayoutResource};
|
||||||
@@ -76,12 +76,31 @@ pub struct CursorPlugin;
|
|||||||
|
|
||||||
impl Plugin for CursorPlugin {
|
impl Plugin for CursorPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
|
// All three read the DragState/game state the input chain and
|
||||||
|
// mutation systems settle, so they run after both. The highlight
|
||||||
|
// painters write card/overlay Sprites and join the visual spine
|
||||||
|
// ahead of the board painters; the cursor icon touches only the
|
||||||
|
// window cursor (#143).
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_cursor_icon,
|
update_cursor_icon
|
||||||
update_drop_highlights.run_if(resource_changed::<DragState>),
|
.after(crate::input_plugin::PointerInput)
|
||||||
update_drop_target_overlays,
|
.after(crate::game_plugin::GameMutation)
|
||||||
|
.after(crate::selection_plugin::SelectionFx)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
|
(
|
||||||
|
update_drop_highlights.run_if(resource_changed::<DragState>),
|
||||||
|
update_drop_target_overlays,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(crate::input_plugin::PointerInput)
|
||||||
|
.after(crate::game_plugin::GameMutation)
|
||||||
|
// Selection maintenance clears DragState and the hint
|
||||||
|
// poll writes highlight sprites — both settle first.
|
||||||
|
.after(crate::selection_plugin::SelectionFx)
|
||||||
|
.after(crate::pending_hint::PendingHint)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -437,7 +456,8 @@ fn tableau_or_stack_pos(
|
|||||||
base.x,
|
base.x,
|
||||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||||
)
|
)
|
||||||
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree {
|
} else if matches!(pile, KlondikePile::Stock) && game.draw_mode() == DrawStockConfig::DrawThree
|
||||||
|
{
|
||||||
let pile_len = game.waste_cards().len();
|
let pile_len = game.waste_cards().len();
|
||||||
let visible_start = pile_len.saturating_sub(3);
|
let visible_start = pile_len.saturating_sub(3);
|
||||||
let slot = index.saturating_sub(visible_start) as f32;
|
let slot = index.saturating_sub(visible_start) as f32;
|
||||||
@@ -581,7 +601,10 @@ mod tests {
|
|||||||
|
|
||||||
use crate::layout::compute_layout;
|
use crate::layout::compute_layout;
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::{GameMode, GameState}};
|
use solitaire_core::{
|
||||||
|
DrawStockConfig,
|
||||||
|
game_state::{GameMode, GameState},
|
||||||
|
};
|
||||||
|
|
||||||
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
/// Builds an `App` with `MinimalPlugins` and the overlay system
|
||||||
/// registered, plus the resources the system needs. Callers
|
/// registered, plus the resources the system needs. Callers
|
||||||
@@ -630,11 +653,7 @@ mod tests {
|
|||||||
// — same colour family, illegal. Tableau(2) must NOT be
|
// — same colour family, illegal. Tableau(2) must NOT be
|
||||||
// highlighted.
|
// highlighted.
|
||||||
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
let mut game = GameState::new_with_mode(7, DrawStockConfig::DrawOne, GameMode::Classic);
|
||||||
set_tableau_top(
|
set_tableau_top(&mut game, 2, Card::new(Deck::Deck1, Suit::Clubs, Rank::Six));
|
||||||
&mut game,
|
|
||||||
2,
|
|
||||||
Card::new(Deck::Deck1, Suit::Clubs, Rank::Six),
|
|
||||||
);
|
|
||||||
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
let dragged = Card::new(Deck::Deck1, Suit::Spades, Rank::Five);
|
||||||
|
|
||||||
let mut app = overlay_test_app(game);
|
let mut app = overlay_test_app(game);
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ use crate::events::{
|
|||||||
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
|
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
|
||||||
WarningToastEvent, XpAwardedEvent,
|
WarningToastEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
@@ -124,17 +124,51 @@ impl Plugin for DailyChallengePlugin {
|
|||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
// record/award after the base ProgressUpdate so we don't fight
|
// Ordering comes from the mode-completion chain configured in
|
||||||
// ProgressPlugin's add_xp on the same frame.
|
// progress_plugin; the toast it emits joins the writer club
|
||||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
// drained by the toast pipeline this frame (#143).
|
||||||
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
.add_systems(
|
||||||
.add_systems(Update, check_daily_expiry_warning)
|
Update,
|
||||||
.add_systems(Update, check_date_rollover);
|
handle_daily_completion
|
||||||
|
.in_set(crate::progress_plugin::DailyCompletion)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// ModeStart places it between the stats abandon-recorder and
|
||||||
|
// GameMutation; it shares the new-game request/toast streams
|
||||||
|
// with the other writers (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
handle_start_daily_request
|
||||||
|
.in_set(crate::game_plugin::ModeStart)
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// One chain for the DailyChallengeResource writers/readers:
|
||||||
|
// the local date rollover replaces today's challenge and the
|
||||||
|
// expiry warning reads it. Before ModeStart so a rollover
|
||||||
|
// lands before the start handler reads the seed this frame
|
||||||
|
// (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
check_date_rollover,
|
||||||
|
check_daily_expiry_warning
|
||||||
|
.in_set(crate::game_plugin::WarningToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::WarningToastWriters),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.before(crate::game_plugin::ModeStart),
|
||||||
|
);
|
||||||
|
|
||||||
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
|
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
|
||||||
|
// The poll replaces DailyChallengeResource, so it heads the same
|
||||||
|
// pre-ModeStart timeline as the rollover/expiry chain above (#143).
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
app.add_systems(Startup, fetch_server_challenge)
|
app.add_systems(Startup, fetch_server_challenge)
|
||||||
.add_systems(Update, poll_server_challenge);
|
.add_systems(Update, poll_server_challenge.before(check_date_rollover));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
|||||||
use solitaire_data::difficulty_seeds::seeds_for;
|
use solitaire_data::difficulty_seeds::seeds_for;
|
||||||
|
|
||||||
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
|
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Resources
|
// Resources
|
||||||
@@ -54,7 +53,10 @@ impl DifficultyIndexResource {
|
|||||||
DifficultyLevel::Hard => &mut self.hard,
|
DifficultyLevel::Hard => &mut self.hard,
|
||||||
DifficultyLevel::Expert => &mut self.expert,
|
DifficultyLevel::Expert => &mut self.expert,
|
||||||
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
DifficultyLevel::Grandmaster => &mut self.grandmaster,
|
||||||
DifficultyLevel::Random => unreachable!("Random has no catalog"),
|
// Random has no catalog today, so seeds_for() already returned
|
||||||
|
// None above; if it ever gains one, a time seed is still the
|
||||||
|
// right answer for "Random" — never a reachable panic.
|
||||||
|
DifficultyLevel::Random => return seed_from_system_time(),
|
||||||
};
|
};
|
||||||
let seed = catalog[*cursor % catalog.len()];
|
let seed = catalog[*cursor % catalog.len()];
|
||||||
*cursor = cursor.wrapping_add(1);
|
*cursor = cursor.wrapping_add(1);
|
||||||
@@ -74,7 +76,16 @@ impl Plugin for DifficultyPlugin {
|
|||||||
app.init_resource::<DifficultyIndexResource>()
|
app.init_resource::<DifficultyIndexResource>()
|
||||||
.add_message::<StartDifficultyRequestEvent>()
|
.add_message::<StartDifficultyRequestEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_systems(Update, handle_difficulty_request.before(GameMutation));
|
// ModeStart places it between the stats abandon-recorder and
|
||||||
|
// GameMutation; it shares the new-game request stream with the
|
||||||
|
// other writers (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
handle_difficulty_request
|
||||||
|
.in_set(crate::game_plugin::ModeStart)
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,9 @@
|
|||||||
|
|
||||||
use bevy::prelude::Message;
|
use bevy::prelude::Message;
|
||||||
use solitaire_core::KlondikePile;
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::{Card, Suit};
|
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::GameMode;
|
||||||
|
use solitaire_core::{Card, Suit};
|
||||||
use solitaire_data::AchievementRecord;
|
use solitaire_data::AchievementRecord;
|
||||||
use solitaire_sync::SyncResponse;
|
|
||||||
|
|
||||||
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
/// Request to move `count` cards from `from` to `to`. Fired by input systems,
|
||||||
/// consumed by `GamePlugin`.
|
/// consumed by `GamePlugin`.
|
||||||
@@ -134,6 +133,12 @@ pub struct ManualSyncRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct SyncConfigureRequestEvent;
|
pub struct SyncConfigureRequestEvent;
|
||||||
|
|
||||||
|
/// Request to open the in-game theme-store modal. Fired by the
|
||||||
|
/// "Browse theme store" button in the Settings cosmetic section;
|
||||||
|
/// handled by `theme_store_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct ThemeStoreOpenRequestEvent;
|
||||||
|
|
||||||
/// Request to disconnect from the current sync backend, clear stored
|
/// Request to disconnect from the current sync backend, clear stored
|
||||||
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
/// credentials, and reset to `SyncBackend::Local`. Fired by the "Disconnect"
|
||||||
/// button in the Settings sync section.
|
/// button in the Settings sync section.
|
||||||
@@ -146,6 +151,18 @@ pub struct SyncLogoutRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct DeleteAccountRequestEvent;
|
pub struct DeleteAccountRequestEvent;
|
||||||
|
|
||||||
|
/// Request to export every JSON save into one local backup bundle
|
||||||
|
/// (Phase M — sync transparency). Fired by the Account tab's "Export
|
||||||
|
/// data" button; handled in the `Last` schedule by `settings_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct DataExportRequestEvent;
|
||||||
|
|
||||||
|
/// Request to restore every JSON save from the local backup bundle
|
||||||
|
/// (Phase M — sync transparency). Fired by the Account tab's "Import
|
||||||
|
/// data" button; handled in the `Last` schedule by `settings_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct DataImportRequestEvent;
|
||||||
|
|
||||||
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
|
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
|
||||||
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
||||||
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
||||||
@@ -153,6 +170,12 @@ pub struct DeleteAccountRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct PauseRequestEvent;
|
pub struct PauseRequestEvent;
|
||||||
|
|
||||||
|
/// Request to solve the current deal and auto-play the winning line.
|
||||||
|
/// Fired by the pause menu's "Show solution" button; consumed by
|
||||||
|
/// `solution_playback_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct ShowSolutionRequestEvent;
|
||||||
|
|
||||||
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
|
/// Request to toggle the help / controls overlay. Fired by the HUD "Help"
|
||||||
/// button alongside the existing `F1` accelerator so the overlay is
|
/// button alongside the existing `F1` accelerator so the overlay is
|
||||||
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
|
/// reachable without a keyboard. Consumed by `help_plugin::toggle_help_screen`.
|
||||||
@@ -231,15 +254,10 @@ pub struct ToggleSettingsRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct ToggleLeaderboardRequestEvent;
|
pub struct ToggleLeaderboardRequestEvent;
|
||||||
|
|
||||||
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
|
/// Request to toggle the Home mode launcher. Fired by the HUD
|
||||||
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
|
/// Menu-popover "Home" row alongside the existing `M` accelerator.
|
||||||
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
/// human-readable failure message (network, auth, serialization, etc.).
|
pub struct ToggleHomeRequestEvent;
|
||||||
///
|
|
||||||
/// UI systems listen for this to refresh views without polling
|
|
||||||
/// `SyncStatusResource`. See [ARCHITECTURE.md §4](../../ARCHITECTURE.md).
|
|
||||||
#[derive(Message, Debug, Clone)]
|
|
||||||
pub struct SyncCompleteEvent(pub Result<SyncResponse, String>);
|
|
||||||
|
|
||||||
/// Generic informational toast message. Any system can fire this to display
|
/// Generic informational toast message. Any system can fire this to display
|
||||||
/// a short string to the player, e.g. "Locked — reach level 5".
|
/// a short string to the player, e.g. "Locked — reach level 5".
|
||||||
@@ -283,15 +301,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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
|
|||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_animation::{MotionCurve, sample_curve};
|
||||||
|
use crate::card_plugin::{CardEntity, CardEntityIndex};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
|
||||||
NewGameRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -197,6 +198,22 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
|||||||
/// animation systems.
|
/// animation systems.
|
||||||
pub struct FeedbackAnimPlugin;
|
pub struct FeedbackAnimPlugin;
|
||||||
|
|
||||||
|
/// Set wrapping the shake/settle/deal/flourish systems. Last anim stage on
|
||||||
|
/// the visual spine: …MarkerVisuals < CardTweenFx <
|
||||||
|
/// [`crate::animation_plugin::AnimFx`] < FeedbackFx. Runs after
|
||||||
|
/// MarkerVisuals because the foundation flourish tints pile-marker sprites,
|
||||||
|
/// which the marker painters also write; the tint must win within the frame
|
||||||
|
/// (#143). GameMutation ordering is inherited transitively.
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct FeedbackFx;
|
||||||
|
|
||||||
|
/// Set wrapping the hint-ghost chain (spawn → tick → despawn). Ghost
|
||||||
|
/// entities are `Without<CardEntity>` sprites owned solely by this chain —
|
||||||
|
/// systems that animate other entity domains (e.g. the win-summary camera
|
||||||
|
/// shake) declare themselves `.ambiguous_with(HintGhostFx)` (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct HintGhostFx;
|
||||||
|
|
||||||
impl Plugin for FeedbackAnimPlugin {
|
impl Plugin for FeedbackAnimPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register the events this plugin consumes so it can run in isolation
|
// Register the events this plugin consumes so it can run in isolation
|
||||||
@@ -207,23 +224,70 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
|
.add_message::<HintVisualEvent>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
.add_message::<RequestRedraw>()
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
FeedbackFx
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::animation_plugin::AnimFx)
|
||||||
|
// HUD text pulses are disjoint from the card/marker
|
||||||
|
// entities this set animates (see UiTextFx ×
|
||||||
|
// BoardVisuals, #143).
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
start_shake_anim.after(GameMutation),
|
// Reads only the reduce-motion flag; a frame of lag is
|
||||||
|
// invisible (#143).
|
||||||
|
start_shake_anim.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
tick_shake_anim,
|
tick_shake_anim,
|
||||||
start_settle_anim.after(GameMutation),
|
start_settle_anim,
|
||||||
// tick_foundation_flourish writes the full Transform.scale
|
// tick_foundation_flourish writes the full Transform.scale
|
||||||
// (Vec3); tick_settle_anim writes only scale.y on top of
|
// (Vec3); tick_settle_anim writes only scale.y on top of
|
||||||
// it. Ordering ensures the settle's y-only write always
|
// it. Ordering ensures the settle's y-only write always
|
||||||
// applies last so it wins on the ~0.15 s overlap when both
|
// applies last so it wins on the ~0.15 s overlap when both
|
||||||
// components are present on the same King entity.
|
// components are present on the same King entity.
|
||||||
tick_foundation_flourish.before(tick_settle_anim),
|
tick_foundation_flourish,
|
||||||
tick_settle_anim,
|
tick_settle_anim,
|
||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim,
|
||||||
start_foundation_flourish.after(GameMutation),
|
start_foundation_flourish,
|
||||||
),
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(FeedbackFx),
|
||||||
|
)
|
||||||
|
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
|
||||||
|
// so it orders after the board painters; the tick only touches
|
||||||
|
// ghost entities (Without<CardEntity>) and stays conflict-free.
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
spawn_hint_ghost
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::card_plugin::BoardVisuals),
|
||||||
|
tick_hint_ghosts,
|
||||||
|
despawn_hint_ghosts_on_state_change.after(GameMutation),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(HintGhostFx)
|
||||||
|
// The spawn reads card Sprites the marker painters (theme
|
||||||
|
// apply, pile highlights) also write — those settle first.
|
||||||
|
// Ghost entities are Without<CardEntity> and never HUD
|
||||||
|
// chrome, and the spawn reads only the reduce-motion flag
|
||||||
|
// (frame-lag invisible) (#143).
|
||||||
|
.after(crate::table_plugin::MarkerVisuals)
|
||||||
|
// …and the card tweens/slides, whose Transform writes
|
||||||
|
// the spawn snapshot must not race.
|
||||||
|
.after(crate::card_animation::CardTweenFx)
|
||||||
|
// FeedbackFx (deal slide, shake) is the last Transform
|
||||||
|
// writer on the spine — the ghost snapshot reads after
|
||||||
|
// the whole visual pipeline has settled.
|
||||||
|
.after(FeedbackFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -276,10 +340,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 +426,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 +657,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.
|
||||||
@@ -646,6 +728,147 @@ fn pile_cards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase H — hint ghost-motion preview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Duration of one ghost glide from the hinted card to its destination.
|
||||||
|
const HINT_GHOST_PASS_SECS: f32 = 0.7;
|
||||||
|
/// How many glides one hint plays before the ghost despawns. Two reads
|
||||||
|
/// as "this move, over there" without outstaying the 2 s static
|
||||||
|
/// highlight it accompanies.
|
||||||
|
const HINT_GHOST_PASSES: f32 = 2.0;
|
||||||
|
/// Ghost translucency — clearly a projection, never mistakable for the
|
||||||
|
/// real card.
|
||||||
|
const HINT_GHOST_ALPHA: f32 = 0.45;
|
||||||
|
/// Ghost render depth: above every settled pile (~1.04 max) and the
|
||||||
|
/// in-flight `CardAnim` lift (50), below a dragged card (500).
|
||||||
|
const HINT_GHOST_Z: f32 = 400.0;
|
||||||
|
|
||||||
|
/// A translucent copy of the hinted card gliding to the suggested
|
||||||
|
/// destination (Phase H). Purely decorative — despawned by timer, by a
|
||||||
|
/// newer hint, or by any state change.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HintGhost {
|
||||||
|
start: Vec3,
|
||||||
|
target: Vec3,
|
||||||
|
elapsed: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalised progress of the current glide pass, restarting from the
|
||||||
|
/// source each pass. Pure for unit testing.
|
||||||
|
fn hint_ghost_pass_t(elapsed: f32) -> f32 {
|
||||||
|
(elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ghost alpha at `pass_t` — full strength for most of the glide, then
|
||||||
|
/// fading over the last 20 % so the loop restart reads as a repeat
|
||||||
|
/// rather than a teleport. Pure for unit testing.
|
||||||
|
fn hint_ghost_alpha(pass_t: f32) -> f32 {
|
||||||
|
let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0);
|
||||||
|
HINT_GHOST_ALPHA * (1.0 - fade_in_tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the ghost when a hint fires. The static highlights (source
|
||||||
|
/// card + gold destination pile) still spawn regardless; under
|
||||||
|
/// reduce-motion they are the whole story and no ghost appears
|
||||||
|
/// (`design-system.md` §Accessibility).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_hint_ghost(
|
||||||
|
mut events: MessageReader<HintVisualEvent>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
index: Option<Res<CardEntityIndex>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
|
||||||
|
existing: Query<Entity, With<HintGhost>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if settings.is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (Some(index), Some(layout)) = (index, layout) else {
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for ev in events.read() {
|
||||||
|
// A fresh hint replaces any ghost still in flight.
|
||||||
|
for entity in &existing {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
let Some(card_entity) = index.get(&ev.source_card) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok((transform, sprite)) = cards.get(card_entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let start = transform.translation.truncate().extend(HINT_GHOST_Z);
|
||||||
|
let mut ghost_sprite = sprite.clone();
|
||||||
|
ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA);
|
||||||
|
commands.spawn((
|
||||||
|
HintGhost {
|
||||||
|
start,
|
||||||
|
target: dest.extend(HINT_GHOST_Z),
|
||||||
|
elapsed: 0.0,
|
||||||
|
},
|
||||||
|
ghost_sprite,
|
||||||
|
Transform::from_translation(start),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every ghost: eased glide per pass, tail fade, despawn after
|
||||||
|
/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other
|
||||||
|
/// decorative animation.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn tick_hint_ghosts(
|
||||||
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut ghosts: Query<
|
||||||
|
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
|
||||||
|
(Without<CardEntity>, Without<PileMarker>),
|
||||||
|
>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
|
||||||
|
ghost.elapsed += dt;
|
||||||
|
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pass_t = hint_ghost_pass_t(ghost.elapsed);
|
||||||
|
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
|
||||||
|
transform.translation = ghost.start.lerp(ghost.target, eased);
|
||||||
|
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ghost previews a move against the *current* board; the moment the
|
||||||
|
/// board changes (move, undo, draw, new game) it is stale and vanishes.
|
||||||
|
fn despawn_hint_ghosts_on_state_change(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
ghosts: Query<Entity, With<HintGhost>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
for entity in &ghosts {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unit tests (pure functions only — no Bevy world required)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -852,7 +1075,10 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(
|
||||||
|
1,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -906,7 +1132,10 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins)
|
app.add_plugins(MinimalPlugins)
|
||||||
.add_plugins(FeedbackAnimPlugin);
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(
|
||||||
|
1,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
)));
|
||||||
app.insert_resource(SettingsResource(Settings {
|
app.insert_resource(SettingsResource(Settings {
|
||||||
reduce_motion_mode: true,
|
reduce_motion_mode: true,
|
||||||
..Settings::default()
|
..Settings::default()
|
||||||
@@ -931,4 +1160,147 @@ mod tests {
|
|||||||
"FoundationFlourish must not be inserted under reduce-motion"
|
"FoundationFlourish must not be inserted under reduce-motion"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H — hint ghost-motion preview
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_pass_t_restarts_each_pass() {
|
||||||
|
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
|
||||||
|
// Just past one full pass, progress wraps back near zero.
|
||||||
|
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_alpha_fades_only_in_the_tail() {
|
||||||
|
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
|
||||||
|
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
|
||||||
|
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
|
||||||
|
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App with one indexed card entity and a real layout, ready to
|
||||||
|
/// receive `HintVisualEvent`s.
|
||||||
|
fn ghost_app(reduce_motion: bool) -> (App, Card) {
|
||||||
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
// Sibling systems in the plugin take GameStateResource
|
||||||
|
// non-optionally; give them a fresh deal.
|
||||||
|
app.insert_resource(GameStateResource(
|
||||||
|
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
|
||||||
|
));
|
||||||
|
app.insert_resource(SettingsResource(solitaire_data::Settings {
|
||||||
|
reduce_motion_mode: reduce_motion,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
CardEntity { card: card.clone() },
|
||||||
|
Sprite::default(),
|
||||||
|
Transform::from_xyz(-100.0, 40.0, 1.0),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let mut index = CardEntityIndex::default();
|
||||||
|
index.0.insert(card.clone(), entity);
|
||||||
|
app.insert_resource(index);
|
||||||
|
app.insert_resource(LayoutResource(crate::layout::compute_layout(
|
||||||
|
Vec2::new(1280.0, 720.0),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
app.update();
|
||||||
|
(app, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_hint(app: &mut App, card: Card) {
|
||||||
|
use solitaire_core::Tableau;
|
||||||
|
app.world_mut().write_message(HintVisualEvent {
|
||||||
|
source_card: card,
|
||||||
|
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ghost_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HintGhost>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_event_spawns_one_ghost_at_the_source_card() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
|
||||||
|
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
|
||||||
|
let transform = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Transform, With<HintGhost>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("ghost transform");
|
||||||
|
// The tick system may have advanced the ghost by one real-time
|
||||||
|
// frame already; assert it is still essentially at the source.
|
||||||
|
assert!(
|
||||||
|
transform
|
||||||
|
.translation
|
||||||
|
.truncate()
|
||||||
|
.distance(Vec2::new(-100.0, 40.0))
|
||||||
|
< 5.0,
|
||||||
|
"ghost must start at the hinted card, got {:?}",
|
||||||
|
transform.translation
|
||||||
|
);
|
||||||
|
assert_eq!(transform.translation.z, HINT_GHOST_Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_suppressed_under_reduce_motion() {
|
||||||
|
let (mut app, card) = ghost_app(true);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
0,
|
||||||
|
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_change_despawns_the_ghost() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
assert_eq!(ghost_count(&mut app), 1);
|
||||||
|
|
||||||
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
0,
|
||||||
|
"a board change makes the previewed move stale — the ghost must vanish"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_hint_replaces_the_first_ghost() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card.clone());
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
1,
|
||||||
|
"re-hinting must replace the in-flight ghost, not stack a second"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -54,12 +54,23 @@ impl Plugin for HelpPlugin {
|
|||||||
.add_message::<TouchInput>()
|
.add_message::<TouchInput>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(toggle_help_screen, handle_help_close_button)
|
||||||
toggle_help_screen,
|
.chain()
|
||||||
handle_help_close_button,
|
// FocusKeys consumes Esc after the modal handlers
|
||||||
scroll_help_panel,
|
// (mirrors toggle_settings_screen, #143).
|
||||||
crate::ui_modal::touch_scroll_panel::<HelpScrollable>,
|
.before(crate::ui_focus::FocusKeys),
|
||||||
),
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
scroll_help_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
crate::ui_modal::touch_scroll_panel::<HelpScrollable>
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,13 +97,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 +599,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"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1215
-316
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
|||||||
|
//! Single source of truth for the desktop keyboard bindings (Phase J).
|
||||||
|
//!
|
||||||
|
//! Two static hotkey tables had already diverged (onboarding's slide
|
||||||
|
//! and Help's controls reference); every future drift multiplies. This
|
||||||
|
//! module owns THE table: the onboarding slide renders the
|
||||||
|
//! [`HotkeyRow::essential`] subset, the hold-`/` cheat sheet
|
||||||
|
//! ([`crate::cheat_sheet_plugin`]) renders everything.
|
||||||
|
//!
|
||||||
|
//! The table is hand-maintained but **pinned by test** against the
|
||||||
|
//! handlers that actually consume each key — adding a binding without
|
||||||
|
//! updating this table (or vice versa) is designed to fail review, not
|
||||||
|
//! runtime. A registry generated from the input systems themselves is
|
||||||
|
//! the eventual ideal; this is the honest 90 % at 1 % of the cost.
|
||||||
|
|
||||||
|
/// One row of the hotkey table.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct HotkeyRow {
|
||||||
|
/// Display form of the key(s), e.g. `"D / Space"`.
|
||||||
|
pub keys: &'static str,
|
||||||
|
/// One-line action description.
|
||||||
|
pub description: &'static str,
|
||||||
|
/// `true` for the beginner-relevant subset the onboarding slide
|
||||||
|
/// shows; the cheat sheet always shows every row.
|
||||||
|
pub essential: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every desktop keyboard binding, in teaching order.
|
||||||
|
pub const HOTKEYS: &[HotkeyRow] = &[
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "D / Space",
|
||||||
|
description: "Draw from stock",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "U",
|
||||||
|
description: "Undo last move (hold to repeat)",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "H",
|
||||||
|
description: "Hint (repeat to cycle alternatives)",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "Tab → Enter",
|
||||||
|
description: "Pick a card; arrows pick where; Enter to drop",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "N",
|
||||||
|
description: "New Classic game",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "M",
|
||||||
|
description: "Open Home (then 1–6 to pick a mode)",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "Esc",
|
||||||
|
description: "Pause / resume; close the top dialog",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "F1",
|
||||||
|
description: "Help / controls",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "S",
|
||||||
|
description: "Stats & progression",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "A",
|
||||||
|
description: "Achievements",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "O",
|
||||||
|
description: "Settings",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "P",
|
||||||
|
description: "Profile",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "L",
|
||||||
|
description: "Leaderboard",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "C",
|
||||||
|
description: "Daily Challenge",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "Z",
|
||||||
|
description: "Zen mode",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "X",
|
||||||
|
description: "Challenge mode",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "T",
|
||||||
|
description: "Time Attack",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "G",
|
||||||
|
description: "Give up the current deal",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "[ / ]",
|
||||||
|
description: "Volume down / up",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "F11",
|
||||||
|
description: "Toggle fullscreen",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "/ (hold)",
|
||||||
|
description: "This cheat sheet",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_is_populated_and_well_formed() {
|
||||||
|
assert!(!HOTKEYS.is_empty());
|
||||||
|
for row in HOTKEYS {
|
||||||
|
assert!(!row.keys.trim().is_empty(), "empty keys cell");
|
||||||
|
assert!(
|
||||||
|
!row.description.trim().is_empty(),
|
||||||
|
"empty description for {}",
|
||||||
|
row.keys
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn essential_subset_is_a_teachable_size() {
|
||||||
|
let essentials = HOTKEYS.iter().filter(|r| r.essential).count();
|
||||||
|
assert!(
|
||||||
|
(6..=12).contains(&essentials),
|
||||||
|
"the onboarding slide wants a skimmable essential set, got {essentials}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_cells_are_unique() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for row in HOTKEYS {
|
||||||
|
assert!(seen.insert(row.keys), "duplicate hotkey row: {}", row.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,438 @@
|
|||||||
|
//! HUD feedback effects: action-bar fades, score pulses and floaters,
|
||||||
|
//! and streak flourishes.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Auto-fade state for the action button bar. The bar fades out when
|
||||||
|
/// the cursor is in the play area (below the HUD band) and back in when
|
||||||
|
/// the cursor approaches the top of the window — same UX as a video
|
||||||
|
/// player's auto-hide controls. Buttons remain fully interactive when
|
||||||
|
/// visible; when faded out they're geometrically out of cursor reach
|
||||||
|
/// (hover requires the cursor to be on a button), so no extra
|
||||||
|
/// pointer-events guard is needed.
|
||||||
|
#[derive(Resource, Debug, Clone, Copy)]
|
||||||
|
pub struct HudActionFade {
|
||||||
|
/// Currently displayed alpha. Lerped toward `target` each frame.
|
||||||
|
pub alpha: f32,
|
||||||
|
/// Where `alpha` is heading — 0.0 (faded out) or 1.0 (visible).
|
||||||
|
pub target: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for HudActionFade {
|
||||||
|
fn default() -> Self {
|
||||||
|
// Start visible so the player sees the controls on first launch
|
||||||
|
// before they've moved the cursor anywhere.
|
||||||
|
Self {
|
||||||
|
alpha: 1.0,
|
||||||
|
target: 1.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How many pixels from the bottom edge the cursor must be to reveal the bar.
|
||||||
|
/// Set slightly taller than `HUD_BAND_HEIGHT` so the bar fades in as the
|
||||||
|
/// cursor approaches, not only when it crosses into the band itself.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||||
|
|
||||||
|
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
||||||
|
/// transition — fast enough to feel responsive without flashing on
|
||||||
|
/// brief cursor wanders into the reveal zone.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_FADE_RATE_PER_SEC: f32 = 6.0;
|
||||||
|
|
||||||
|
/// Updates the fade state from cursor position. Sets `target = 1.0` if
|
||||||
|
/// the cursor is in the reveal zone (bottom of window) or off-screen
|
||||||
|
/// (player is using keyboard); `0.0` otherwise. Lerps `alpha` toward
|
||||||
|
/// `target` at a fixed rate so the visual transition is smooth across
|
||||||
|
/// variable framerates.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
pub(super) fn update_action_fade(
|
||||||
|
windows: Query<&Window>,
|
||||||
|
time: Res<Time>,
|
||||||
|
mut fade: ResMut<HudActionFade>,
|
||||||
|
) {
|
||||||
|
let Ok(window) = windows.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let height = window.resolution.height();
|
||||||
|
fade.target = match window.cursor_position() {
|
||||||
|
Some(pos) if pos.y >= height - ACTION_FADE_REVEAL_PX => 1.0,
|
||||||
|
Some(_) => 0.0,
|
||||||
|
// Off-window cursor: assume keyboard navigation and keep the
|
||||||
|
// bar visible so Tab cycling doesn't lead to invisible focus.
|
||||||
|
None => 1.0,
|
||||||
|
};
|
||||||
|
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
let max_step = ACTION_FADE_RATE_PER_SEC * dt;
|
||||||
|
let diff = fade.target - fade.alpha;
|
||||||
|
fade.alpha = (fade.alpha + diff.clamp(-max_step, max_step)).clamp(0.0, 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies the current fade alpha to every action button's
|
||||||
|
/// `BackgroundColor` and to its child label / hotkey-chip text. Runs in
|
||||||
|
/// `Last` (after `paint_action_buttons`) so a hover-state change in the
|
||||||
|
/// same frame doesn't override the fade with an opaque idle / hover
|
||||||
|
/// colour.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn apply_action_fade(
|
||||||
|
fade: Res<HudActionFade>,
|
||||||
|
// Excludes `PopoverRow` so the auto-fade only applies to the
|
||||||
|
// top-level action bar buttons. Popover rows live inside an
|
||||||
|
// explicitly-opened dropdown panel and need to stay visible
|
||||||
|
// regardless of the bar's fade state — without the exclusion
|
||||||
|
// the rows fade to invisible while the popover container stays
|
||||||
|
// visible, leaving a solid background block with no readable
|
||||||
|
// content.
|
||||||
|
mut buttons: Query<
|
||||||
|
(&Children, &mut BackgroundColor),
|
||||||
|
(With<ActionButton>, Without<PopoverRow>),
|
||||||
|
>,
|
||||||
|
mut text_q: Query<&mut TextColor>,
|
||||||
|
) {
|
||||||
|
for (children, mut bg) in &mut buttons {
|
||||||
|
let mut c = bg.0;
|
||||||
|
c.set_alpha(fade.alpha);
|
||||||
|
bg.0 = c;
|
||||||
|
for child in children.iter() {
|
||||||
|
if let Ok(mut tc) = text_q.get_mut(child) {
|
||||||
|
let mut cc = tc.0;
|
||||||
|
cc.set_alpha(fade.alpha);
|
||||||
|
tc.0 = cc;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||||
|
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||||
|
/// changes. One query covers all action buttons via the shared
|
||||||
|
/// `ActionButton` marker. Glass tab-bar buttons are excluded: they carry
|
||||||
|
/// their own per-button colour triple and are painted by
|
||||||
|
/// `paint_glass_tab_buttons` instead (this system's opaque `ACTION_BTN_*`
|
||||||
|
/// fills would destroy the translucent glass look).
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn paint_action_buttons(
|
||||||
|
mut buttons: Query<
|
||||||
|
(&Interaction, &mut BackgroundColor),
|
||||||
|
(
|
||||||
|
With<ActionButton>,
|
||||||
|
Without<GlassTabButton>,
|
||||||
|
Changed<Interaction>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
for (interaction, mut bg) in &mut buttons {
|
||||||
|
bg.0 = match interaction {
|
||||||
|
Interaction::Pressed => ACTION_BTN_PRESSED,
|
||||||
|
Interaction::Hovered => ACTION_BTN_HOVER,
|
||||||
|
Interaction::None => ACTION_BTN_IDLE,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Triangular 1.0 → 1.1 → 1.0 curve used by the score pulse. Pure
|
||||||
|
/// function so the test suite can assert on the curve directly
|
||||||
|
/// without spinning up a Bevy app.
|
||||||
|
///
|
||||||
|
/// The brief proposed `if t < 0.5 { 1.0 + 0.2*t } else { 1.2 - 0.2*(t-0.5) }`,
|
||||||
|
/// but that yields a discontinuity at t=0.5 (jumps from 1.1 → 1.2) and
|
||||||
|
/// ends at 1.1 instead of 1.0. The corrected form below preserves the
|
||||||
|
/// intent ("1.0 → 1.1 → 1.0 over the duration") with a continuous
|
||||||
|
/// triangle peaking at 1.1.
|
||||||
|
pub(super) fn score_pulse_scale(t: f32) -> f32 {
|
||||||
|
let clamped = t.clamp(0.0, 1.0);
|
||||||
|
if clamped < 0.5 {
|
||||||
|
1.0 + 0.2 * clamped
|
||||||
|
} else {
|
||||||
|
1.1 - 0.2 * (clamped - 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical pixels the floating "+N" drifts up over its lifetime.
|
||||||
|
const FLOATER_DRIFT_PX: f32 = 40.0;
|
||||||
|
|
||||||
|
/// Diffs the current `GameStateResource.score` against
|
||||||
|
/// [`PreviousScore`]. On a positive delta:
|
||||||
|
///
|
||||||
|
/// - Inserts (or refreshes) a [`ScorePulse`] on every [`HudScore`] entity
|
||||||
|
/// so the readout pulses 1.0 → 1.1 → 1.0.
|
||||||
|
/// - When the delta is ≥ [`SCORE_FLOATER_THRESHOLD`], spawns a floating
|
||||||
|
/// "+N" UI text in `ACCENT_PRIMARY` anchored just below the score
|
||||||
|
/// readout (see the doc comment on [`ScoreFloater`] for why this is a
|
||||||
|
/// UI Node rather than a `Text2d`).
|
||||||
|
pub(super) fn detect_score_change(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut prev: ResMut<PreviousScore>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
score_q: Query<Entity, With<HudScore>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let current = game.0.score();
|
||||||
|
let delta = current - prev.0;
|
||||||
|
prev.0 = current;
|
||||||
|
if delta <= 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
if reduce_motion {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let pulse_secs = scaled_duration(MOTION_SCORE_PULSE_SECS, speed);
|
||||||
|
let floater_secs = scaled_duration(MOTION_SCORE_PULSE_SECS * 2.0, speed);
|
||||||
|
|
||||||
|
// Refresh ScorePulse on every score readout entity (in practice
|
||||||
|
// there's exactly one, but iterating is cheaper than asserting).
|
||||||
|
for entity in &score_q {
|
||||||
|
commands.entity(entity).insert(ScorePulse {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: pulse_secs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if delta < SCORE_FLOATER_THRESHOLD {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
// Spawned as an absolutely-positioned UI Node so the floater rides
|
||||||
|
// the same screen-coordinate system as the score readout. Using a
|
||||||
|
// `Text2d` here would require translating UI layout coordinates to
|
||||||
|
// world space every frame; a UI node piggybacks on the same
|
||||||
|
// anchoring `update_hud` already uses for the score and stays
|
||||||
|
// testable under `MinimalPlugins`.
|
||||||
|
commands.spawn((
|
||||||
|
ScoreFloater {
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration: floater_secs,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
// Anchored next to the HUD column; matches the
|
||||||
|
// `spawn_hud` left/top offsets so the floater appears
|
||||||
|
// overlaid on the score line and drifts up from there.
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD_TOP),
|
||||||
|
Text::new(format!("+{delta}")),
|
||||||
|
font,
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`ScorePulse`], scaling its entity's `Transform`
|
||||||
|
/// using [`score_pulse_scale`]. Removes the component once
|
||||||
|
/// `elapsed >= duration` (or immediately under
|
||||||
|
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) where duration is
|
||||||
|
/// 0) and pins the scale back to 1.0 so no float drift survives.
|
||||||
|
pub(super) fn advance_score_pulse(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<(Entity, &mut ScorePulse, &mut Transform)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut pulse, mut transform) in &mut q {
|
||||||
|
let t = if pulse.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
pulse.elapsed += dt;
|
||||||
|
(pulse.elapsed / pulse.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = score_pulse_scale(t);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
commands.entity(entity).remove::<ScorePulse>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`ScoreFloater`]: drifts the node upward by up to
|
||||||
|
/// [`FLOATER_DRIFT_PX`] and fades the text colour to transparent over
|
||||||
|
/// its lifetime. Despawns the entity once `elapsed >= duration`.
|
||||||
|
pub(super) fn advance_score_floater(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut nodes: Query<(Entity, &mut ScoreFloater, &mut Node, &mut TextColor)>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut floater, mut node, mut color) in &mut nodes {
|
||||||
|
let t = if floater.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
floater.elapsed += dt;
|
||||||
|
(floater.elapsed / floater.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
// Drift upward: top decreases as t grows. Starting top=0 keeps
|
||||||
|
// the floater on the score line; ending at -FLOATER_DRIFT_PX
|
||||||
|
// pulls it up off the readout.
|
||||||
|
node.top = Val::Px(-FLOATER_DRIFT_PX * t);
|
||||||
|
// Linear fade: ACCENT_PRIMARY at t=0 → fully transparent at t=1.
|
||||||
|
let mut c = ACCENT_PRIMARY;
|
||||||
|
c.set_alpha(1.0 - t);
|
||||||
|
color.0 = c;
|
||||||
|
if t >= 1.0 {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Streak-milestone flourish
|
||||||
|
//
|
||||||
|
// Per the 2026-04-30 UX overhaul plan, the foundation flourish is the per-suit
|
||||||
|
// completion celebration; the streak flourish is its lifetime equivalent —
|
||||||
|
// when the player's `win_streak_current` crosses 3, 5, or 10, the HUD score
|
||||||
|
// readout pulses larger than a normal score-change pulse and tints magenta
|
||||||
|
// (`ACCENT_SECONDARY`) before snapping back to its resting state.
|
||||||
|
//
|
||||||
|
// Why the score readout: there is no always-on streak number on the HUD
|
||||||
|
// today (the readout lives in the Stats overlay), and the score is the
|
||||||
|
// most prominent always-visible HUD figure. The accompanying `InfoToastEvent`
|
||||||
|
// fired by `stats_plugin` carries the explicit "Win streak: N!" text so a
|
||||||
|
// player who isn't watching the score still sees the celebration land.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pure helper for unit tests — returns the per-frame scale factor for
|
||||||
|
/// the streak flourish at `elapsed_secs` over `duration_secs`.
|
||||||
|
///
|
||||||
|
/// Triangular curve, mirroring [`foundation_flourish_scale`](crate::feedback_anim_plugin::foundation_flourish_scale):
|
||||||
|
/// at `t = 0.0` returns `1.0`, at `t = 0.5` returns
|
||||||
|
/// [`STREAK_FLOURISH_PEAK_SCALE`], at `t = 1.0` returns `1.0`.
|
||||||
|
/// Out-of-range values are clamped so the score readout never freezes
|
||||||
|
/// at a non-1.0 scale on the frame after the flourish ends.
|
||||||
|
///
|
||||||
|
/// Returns `1.0` whenever `duration_secs <= 0.0` so callers running
|
||||||
|
/// under `AnimSpeed::Instant` (zeroed durations) skip the flourish
|
||||||
|
/// without dividing by zero.
|
||||||
|
pub fn streak_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||||
|
if duration_secs <= 0.0 {
|
||||||
|
return 1.0;
|
||||||
|
}
|
||||||
|
let t = (elapsed_secs / duration_secs).clamp(0.0, 1.0);
|
||||||
|
let peak = STREAK_FLOURISH_PEAK_SCALE;
|
||||||
|
if t < 0.5 {
|
||||||
|
// Climb from 1.0 at t=0 to peak at t=0.5.
|
||||||
|
1.0 + (peak - 1.0) * (t / 0.5)
|
||||||
|
} else {
|
||||||
|
// Descend from peak at t=0.5 back to 1.0 at t=1.0.
|
||||||
|
peak - (peak - 1.0) * ((t - 0.5) / 0.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Inserts a [`StreakFlourish`] on every [`HudScore`] entity when a
|
||||||
|
/// [`WinStreakMilestoneEvent`] fires. Captures the readout's current
|
||||||
|
/// `TextColor` so `advance_streak_flourish` can restore it when the
|
||||||
|
/// timer expires; reuses any existing flourish's `original_color` so
|
||||||
|
/// re-entering the system mid-flourish doesn't snapshot the magenta
|
||||||
|
/// tint as the new "original".
|
||||||
|
///
|
||||||
|
/// Removes any concurrent [`ScorePulse`] from the same entity so the
|
||||||
|
/// flourish takes over the scale slot cleanly — score pulses last
|
||||||
|
/// 250 ms, the flourish 600 ms, and the streak crossing always
|
||||||
|
/// coincides with a positive score delta, so the flourish is the
|
||||||
|
/// louder of the two celebrations.
|
||||||
|
pub(super) fn start_streak_flourish(
|
||||||
|
mut events: MessageReader<WinStreakMilestoneEvent>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
score_q: Query<(Entity, &TextColor, Option<&StreakFlourish>), With<HudScore>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let Some(latest) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let speed = settings
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.0.animation_speed)
|
||||||
|
.unwrap_or_default();
|
||||||
|
let duration = scaled_duration(MOTION_STREAK_FLOURISH_SECS, speed);
|
||||||
|
for (entity, color, existing) in &score_q {
|
||||||
|
let original_color = existing.map_or(color.0, |f| f.original_color);
|
||||||
|
commands
|
||||||
|
.entity(entity)
|
||||||
|
.remove::<ScorePulse>()
|
||||||
|
.insert(StreakFlourish {
|
||||||
|
streak: latest.streak,
|
||||||
|
elapsed: 0.0,
|
||||||
|
duration,
|
||||||
|
original_color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every [`StreakFlourish`], scaling its entity's `Transform`
|
||||||
|
/// using [`streak_flourish_scale`] and lerping the `TextColor` toward
|
||||||
|
/// [`ACCENT_SECONDARY`] for the first half then back to the captured
|
||||||
|
/// `original_color`. Removes the component once `elapsed >= duration`
|
||||||
|
/// (or immediately under [`AnimSpeed::Instant`](solitaire_data::AnimSpeed)
|
||||||
|
/// where duration is 0) and pins the scale back to 1.0 / restores the
|
||||||
|
/// original colour so no half-state is ever shown.
|
||||||
|
///
|
||||||
|
/// Filtered with `Without<ScorePulse>` so the streak flourish never
|
||||||
|
/// races a score pulse for the same `Transform.scale` slot —
|
||||||
|
/// `start_streak_flourish` strips any concurrent `ScorePulse` from the
|
||||||
|
/// score entity before this system runs, so the filter is purely a
|
||||||
|
/// belt-and-braces invariant.
|
||||||
|
pub(super) fn advance_streak_flourish(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut commands: Commands,
|
||||||
|
mut q: Query<
|
||||||
|
(Entity, &mut StreakFlourish, &mut Transform, &mut TextColor),
|
||||||
|
Without<ScorePulse>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut anim, mut transform, mut color) in &mut q {
|
||||||
|
let t = if anim.duration <= 0.0 {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
anim.elapsed += dt;
|
||||||
|
(anim.elapsed / anim.duration).clamp(0.0, 1.0)
|
||||||
|
};
|
||||||
|
let scale = streak_flourish_scale(anim.elapsed, anim.duration);
|
||||||
|
transform.scale = Vec3::new(scale, scale, 1.0);
|
||||||
|
// Tint mix: full magenta at t=0..=0.5, fades back to the
|
||||||
|
// original colour over t=0.5..=1.0.
|
||||||
|
let mix = if t < 0.5 { 1.0 } else { 1.0 - (t - 0.5) / 0.5 };
|
||||||
|
color.0 = lerp_text_color(anim.original_color, ACCENT_SECONDARY, mix);
|
||||||
|
if t >= 1.0 {
|
||||||
|
transform.scale = Vec3::ONE;
|
||||||
|
color.0 = anim.original_color;
|
||||||
|
commands.entity(entity).remove::<StreakFlourish>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// sRGB-space linear interpolation between two `Color`s — small local
|
||||||
|
/// helper so `advance_streak_flourish` stays readable. sRGB-space
|
||||||
|
/// lerping is fine for a brief decorative tint (a perceptually-uniform
|
||||||
|
/// space would be overkill).
|
||||||
|
pub(super) fn lerp_text_color(from: Color, to: Color, t: f32) -> Color {
|
||||||
|
let from = from.to_srgba();
|
||||||
|
let to = to.to_srgba();
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
Color::srgba(
|
||||||
|
from.red + (to.red - from.red) * t,
|
||||||
|
from.green + (to.green - from.green) * t,
|
||||||
|
from.blue + (to.blue - from.blue) * t,
|
||||||
|
from.alpha + (to.alpha - from.alpha) * t,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,724 @@
|
|||||||
|
//! HUD interaction: action-button handlers, Modes/Menu popovers, and
|
||||||
|
//! the chrome tap-to-toggle gesture.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `Changed<Interaction>` filter ensures we only react on the frame the
|
||||||
|
/// interaction state transitions, avoiding repeat events while the button
|
||||||
|
/// is held down. Each click handler fires the corresponding request event,
|
||||||
|
/// which `pause_plugin` / `help_plugin` / `game_plugin` consume alongside
|
||||||
|
/// their existing keyboard handlers.
|
||||||
|
pub(super) fn handle_new_game_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<NewGameButton>, Changed<Interaction>)>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_undo_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<UndoButton>, Changed<Interaction>)>,
|
||||||
|
mut undo: MessageWriter<UndoRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
undo.write(UndoRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Seconds the Undo button must be continuously held before hold-to-repeat
|
||||||
|
/// kicks in. Long enough that a normal tap (press + release inside one or
|
||||||
|
/// two frames) never triggers a second undo.
|
||||||
|
const UNDO_HOLD_INITIAL_DELAY_SECS: f32 = 0.45;
|
||||||
|
|
||||||
|
/// Interval between repeated undos while the hold continues. ~5.5 undos/s —
|
||||||
|
/// fast enough to unwind a long line, slow enough to release in time when
|
||||||
|
/// the board reaches the state the player wants.
|
||||||
|
const UNDO_HOLD_REPEAT_INTERVAL_SECS: f32 = 0.18;
|
||||||
|
|
||||||
|
/// Hold-to-repeat undo (Phase F): while the Undo button stays pressed,
|
||||||
|
/// fire additional [`UndoRequestEvent`]s after an initial delay, one per
|
||||||
|
/// repeat interval. The plain tap path stays in [`handle_undo_button`]
|
||||||
|
/// (its `Changed<Interaction>` filter fires exactly once per press);
|
||||||
|
/// this system only adds events once the hold outlives the delay, so a
|
||||||
|
/// tap never double-undoes.
|
||||||
|
///
|
||||||
|
/// Each repeat goes through the normal request queue — the scoring
|
||||||
|
/// penalty and No-Undo-mode gating in the consumer apply to every step.
|
||||||
|
pub(super) fn repeat_undo_on_hold(
|
||||||
|
time: Res<Time>,
|
||||||
|
buttons: Query<&Interaction, With<UndoButton>>,
|
||||||
|
mut state: ResMut<UndoHoldState>,
|
||||||
|
mut undo: MessageWriter<UndoRequestEvent>,
|
||||||
|
) {
|
||||||
|
let held = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !held {
|
||||||
|
if state.held_secs != 0.0 {
|
||||||
|
state.held_secs = 0.0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = state.held_secs;
|
||||||
|
state.held_secs += time.delta_secs();
|
||||||
|
if undo_hold_crossed_fire_boundary(before, state.held_secs) {
|
||||||
|
undo.write(UndoRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when a hold that lasted `before` seconds at the previous frame
|
||||||
|
/// and `now` seconds this frame should fire a repeat undo: once when the
|
||||||
|
/// hold first outlives the initial delay, then once per repeat-interval
|
||||||
|
/// boundary. Pure so the timing contract is unit-testable without
|
||||||
|
/// fighting `Time`.
|
||||||
|
pub(super) fn undo_hold_crossed_fire_boundary(before: f32, now: f32) -> bool {
|
||||||
|
if now < UNDO_HOLD_INITIAL_DELAY_SECS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if before < UNDO_HOLD_INITIAL_DELAY_SECS {
|
||||||
|
// Crossed the initial-delay threshold this frame — first repeat.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fire once each time the hold crosses another repeat-interval boundary.
|
||||||
|
let intervals_before =
|
||||||
|
((before - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
|
||||||
|
let intervals_now =
|
||||||
|
((now - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
|
||||||
|
intervals_now > intervals_before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click on the touch action bar's Draw button — same
|
||||||
|
/// [`DrawRequestEvent`] the stock-pile tap writes, so the consumer's
|
||||||
|
/// rules (recycle, won-game rejection) apply identically. Skipped while
|
||||||
|
/// paused, mirroring `handle_stock_click`'s guard.
|
||||||
|
pub(super) fn handle_draw_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<DrawButton>, Changed<Interaction>)>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut draw: MessageWriter<DrawRequestEvent>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
draw.write(DrawRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_pause_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
||||||
|
mut pause: MessageWriter<PauseRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
pause.write(PauseRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_help_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HelpButton>, Changed<Interaction>)>,
|
||||||
|
mut help: MessageWriter<HelpRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
help.write(HelpRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn handle_hint_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HintButton>, Changed<Interaction>)>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
solver_config: Option<Res<crate::input_plugin::HintSolverConfig>>,
|
||||||
|
mut pending_hint: Option<ResMut<crate::pending_hint::PendingHintTask>>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if paused.as_ref().is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(ref g) = game else { return };
|
||||||
|
if g.0.is_won() {
|
||||||
|
info_toast.write(InfoToastEvent(HINT_WON_MSG.to_string()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let (Some(cfg), Some(hint)) = (solver_config.as_ref(), pending_hint.as_mut()) {
|
||||||
|
hint.spawn(g.0.clone(), cfg.moves_budget, cfg.states_budget);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the [`ModesPopover`]: spawns it on first click, despawns it on
|
||||||
|
/// second click. Mode rows are populated per the player's current level so
|
||||||
|
/// only unlocked options appear.
|
||||||
|
pub(super) fn handle_modes_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<ModesButton>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
spawn_modes_popover(
|
||||||
|
&mut commands,
|
||||||
|
progress.as_deref(),
|
||||||
|
daily.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the modes popover anchored just below the action bar's right
|
||||||
|
/// edge. Always includes Classic; includes Daily Challenge when a daily
|
||||||
|
/// resource is loaded; includes Zen / Challenge / Time Attack once the
|
||||||
|
/// player reaches the challenge unlock level.
|
||||||
|
pub(super) fn spawn_modes_popover(
|
||||||
|
commands: &mut Commands,
|
||||||
|
progress: Option<&ProgressResource>,
|
||||||
|
daily: Option<&DailyChallengeResource>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let level = progress.map_or(0, |p| p.0.level);
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 15.0,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Each row carries a tooltip alongside its label so hover reveals
|
||||||
|
// a one-line description of what the mode does — mirroring the
|
||||||
|
// tooltips on the action-bar buttons that opened this popover.
|
||||||
|
let mut rows: Vec<(ModeOption, &'static str, &'static str)> = vec![(
|
||||||
|
ModeOption::Classic,
|
||||||
|
"Classic",
|
||||||
|
"Standard Klondike. Score, timer, and full progression.",
|
||||||
|
)];
|
||||||
|
if daily.is_some() {
|
||||||
|
rows.push((
|
||||||
|
ModeOption::DailyChallenge,
|
||||||
|
"Daily Challenge",
|
||||||
|
"Today's seeded deal. Same for every player worldwide.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if level >= CHALLENGE_UNLOCK_LEVEL {
|
||||||
|
rows.push((
|
||||||
|
ModeOption::Zen,
|
||||||
|
"Zen",
|
||||||
|
"No timer, no score, no penalties. Just play.",
|
||||||
|
));
|
||||||
|
rows.push((
|
||||||
|
ModeOption::Challenge,
|
||||||
|
"Challenge",
|
||||||
|
"Hand-picked hard seeds. No undo allowed.",
|
||||||
|
));
|
||||||
|
rows.push((
|
||||||
|
ModeOption::TimeAttack,
|
||||||
|
"Time Attack",
|
||||||
|
"Win as many games as you can in ten minutes.",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Popover opens upward from just above the bottom action bar.
|
||||||
|
// Use a platform-aware offset that clears the bar height + safe-area
|
||||||
|
// gesture zone on Android, and the flat bar height on desktop.
|
||||||
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
ModesPopover,
|
||||||
|
HudPopoverOpen,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
bottom: popover_bottom,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
ZIndex(Z_HUD_POPOVER),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
for (option, label, tooltip) in rows {
|
||||||
|
panel
|
||||||
|
.spawn((
|
||||||
|
option,
|
||||||
|
ActionButton,
|
||||||
|
PopoverRow,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
|
||||||
|
justify_content: JustifyContent::FlexStart,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
min_width: Val::Px(150.0),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fullscreen transparent backdrop at Z_HUD_POPOVER_BACKDROP (below the
|
||||||
|
// popover at Z_HUD_POPOVER) so tapping outside light-dismisses it.
|
||||||
|
commands.spawn((
|
||||||
|
ModesPopoverBackdrop,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches the click on a popover row to the matching request event,
|
||||||
|
/// then despawns the popover.
|
||||||
|
///
|
||||||
|
/// Classic uses [`NewGameRequestEvent`] directly; the other modes use
|
||||||
|
/// their `Start*RequestEvent` so the existing keyboard handler runs
|
||||||
|
/// (level gates, `TimeAttackResource` setup, daily seed lookup, etc.) —
|
||||||
|
/// the popover stays a thin entry point and never duplicates that logic.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_mode_option_click(
|
||||||
|
interaction_query: Query<(&Interaction, &ModeOption), Changed<Interaction>>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut new_game: MessageWriter<NewGameRequestEvent>,
|
||||||
|
mut zen: MessageWriter<StartZenRequestEvent>,
|
||||||
|
mut challenge: MessageWriter<StartChallengeRequestEvent>,
|
||||||
|
mut time_attack: MessageWriter<StartTimeAttackRequestEvent>,
|
||||||
|
mut daily: MessageWriter<StartDailyChallengeRequestEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let mut clicked_any = false;
|
||||||
|
for (interaction, option) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clicked_any = true;
|
||||||
|
match option {
|
||||||
|
ModeOption::Classic => {
|
||||||
|
new_game.write(NewGameRequestEvent::default());
|
||||||
|
}
|
||||||
|
ModeOption::DailyChallenge => {
|
||||||
|
daily.write(StartDailyChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::Zen => {
|
||||||
|
zen.write(StartZenRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::Challenge => {
|
||||||
|
challenge.write(StartChallengeRequestEvent);
|
||||||
|
}
|
||||||
|
ModeOption::TimeAttack => {
|
||||||
|
time_attack.write(StartTimeAttackRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clicked_any && let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggles the [`MenuPopover`]: spawns it on first click, despawns it on
|
||||||
|
/// second click. The popover lists the five overlays previously only
|
||||||
|
/// reachable via the S / A / P / O / L hotkeys.
|
||||||
|
pub(super) fn handle_menu_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<MenuButton>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
} else if scrims.is_empty() {
|
||||||
|
spawn_menu_popover(&mut commands, font_res.as_deref());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the menu popover anchored just below the action bar, with one
|
||||||
|
/// row per overlay. Each row dispatches its corresponding
|
||||||
|
/// `Toggle*RequestEvent` so the existing toggle handler runs (and the
|
||||||
|
/// HUD never duplicates spawn / despawn / fetch logic).
|
||||||
|
pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 15.0,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// One popover row: destination, label, hover tooltip.
|
||||||
|
type MenuRow = (MenuOption, &'static str, &'static str);
|
||||||
|
// Destinations grouped into labelled sections (Phase C of the menu
|
||||||
|
// 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] = [
|
||||||
|
(
|
||||||
|
"Play",
|
||||||
|
&[(
|
||||||
|
MenuOption::Home,
|
||||||
|
"Home",
|
||||||
|
"Pick a mode, continue, or start a new game.",
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"You",
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
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.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"Community",
|
||||||
|
&[(
|
||||||
|
MenuOption::Leaderboard,
|
||||||
|
"Leaderboard",
|
||||||
|
"Top players from your sync server. Opt in from Profile.",
|
||||||
|
)],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"System",
|
||||||
|
&[
|
||||||
|
(
|
||||||
|
MenuOption::Settings,
|
||||||
|
"Settings",
|
||||||
|
"Audio, animations, theme, draw mode, and sync.",
|
||||||
|
),
|
||||||
|
(
|
||||||
|
MenuOption::Help,
|
||||||
|
"Help",
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Same upward-opening placement as ModesPopover.
|
||||||
|
let popover_bottom = Val::Px(ACTION_POPOVER_BOTTOM_PX);
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
MenuPopover,
|
||||||
|
HudPopoverOpen,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
bottom: popover_bottom,
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
padding: UiRect::all(VAL_SPACE_2),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
ZIndex(Z_HUD_POPOVER),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
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
|
||||||
|
.spawn(Node {
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|b| {
|
||||||
|
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)));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transparent fullscreen backdrop behind the popover — tapping anywhere
|
||||||
|
// outside the panel light-dismisses it via handle_menu_backdrop_click.
|
||||||
|
commands.spawn((
|
||||||
|
MenuPopoverBackdrop,
|
||||||
|
Button,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
top: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(Color::NONE),
|
||||||
|
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches the click on a menu row to the matching toggle event,
|
||||||
|
/// then despawns the popover.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_menu_option_click(
|
||||||
|
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut home: MessageWriter<ToggleHomeRequestEvent>,
|
||||||
|
mut stats: MessageWriter<ToggleStatsRequestEvent>,
|
||||||
|
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
|
||||||
|
mut profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
|
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
|
||||||
|
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
|
||||||
|
mut help: MessageWriter<HelpRequestEvent>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let mut clicked_any = false;
|
||||||
|
for (interaction, option) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
clicked_any = true;
|
||||||
|
match option {
|
||||||
|
MenuOption::Home => {
|
||||||
|
home.write(ToggleHomeRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Help => {
|
||||||
|
help.write(HelpRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Stats => {
|
||||||
|
stats.write(ToggleStatsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Achievements => {
|
||||||
|
achievements.write(ToggleAchievementsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Profile => {
|
||||||
|
profile.write(ToggleProfileRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Settings => {
|
||||||
|
settings.write(ToggleSettingsRequestEvent);
|
||||||
|
}
|
||||||
|
MenuOption::Leaderboard => {
|
||||||
|
leaderboard.write(ToggleLeaderboardRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clicked_any && let Ok(entity) = popovers.single() {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
for e in &backdrops {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
|
||||||
|
/// is pressed while the popover is open. Runs so `PausePlugin`'s guard (which
|
||||||
|
/// checks [`HudPopoverOpen`]) sees an empty world and stays idle.
|
||||||
|
pub(super) fn close_modes_popover_on_escape(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`MenuPopover`] and its backdrop when Escape / Android back
|
||||||
|
/// is pressed while the popover is open.
|
||||||
|
pub(super) fn close_menu_popover_on_escape(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !keys.just_pressed(KeyCode::Escape) || popovers.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`ModesPopover`] and its backdrop when the player taps
|
||||||
|
/// anywhere outside the panel.
|
||||||
|
pub(super) fn handle_modes_backdrop_click(
|
||||||
|
interaction_query: Query<&Interaction, (With<ModesPopoverBackdrop>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<ModesPopover>>,
|
||||||
|
backdrops: Query<Entity, With<ModesPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Despawns the [`MenuPopover`] and its backdrop when the player taps
|
||||||
|
/// anywhere outside the panel (i.e. the transparent backdrop is pressed).
|
||||||
|
pub(super) fn handle_menu_backdrop_click(
|
||||||
|
interaction_query: Query<&Interaction, (With<MenuPopoverBackdrop>, Changed<Interaction>)>,
|
||||||
|
popovers: Query<Entity, With<MenuPopover>>,
|
||||||
|
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let pressed = interaction_query.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !pressed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for e in popovers.iter().chain(backdrops.iter()) {
|
||||||
|
commands.entity(e).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn toggle_hud_on_tap(
|
||||||
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut tracker: ResMut<HudTapTracker>,
|
||||||
|
mut hud_vis: ResMut<HudVisibility>,
|
||||||
|
buttons: Query<&Interaction, With<ActionButton>>,
|
||||||
|
mut game_consumed: ResMut<GameInputConsumedResource>,
|
||||||
|
) {
|
||||||
|
use bevy::input::touch::TouchPhase;
|
||||||
|
if !scrims.is_empty() || paused.is_some_and(|p| p.0) {
|
||||||
|
// Drain buffered events so they don't replay in the frame after
|
||||||
|
// the scrim despawns, which would trigger a spurious visibility
|
||||||
|
// toggle as the resume/close button tap's Started+Ended pair
|
||||||
|
// replays in the now-scrim-free frame.
|
||||||
|
for _ in touch_events.read() {}
|
||||||
|
tracker.start_pos = None;
|
||||||
|
tracker.started_on_button = false;
|
||||||
|
game_consumed.0 = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for event in touch_events.read() {
|
||||||
|
match event.phase {
|
||||||
|
TouchPhase::Started => {
|
||||||
|
tracker.start_pos = Some(event.position);
|
||||||
|
// Record whether the finger-down landed on a button so
|
||||||
|
// the finger-up doesn't double-fire (toggle bar + press
|
||||||
|
// button at the same time).
|
||||||
|
tracker.started_on_button = buttons.iter().any(|i| *i != Interaction::None);
|
||||||
|
}
|
||||||
|
TouchPhase::Ended if drag.is_idle() => {
|
||||||
|
// Also treat taps where game logic consumed the touch (e.g.
|
||||||
|
// drawing from stock) as "on button" so they don't toggle
|
||||||
|
// the HUD. The flag is set on TouchPhase::Started by the
|
||||||
|
// input system that consumed the tap and must be cleared here
|
||||||
|
// regardless of whether we toggle.
|
||||||
|
let on_button = tracker.started_on_button || game_consumed.0;
|
||||||
|
game_consumed.0 = false;
|
||||||
|
if let Some(start) = tracker.start_pos.take()
|
||||||
|
&& !on_button
|
||||||
|
&& (event.position - start).length() < HUD_TAP_SLOP_PX
|
||||||
|
{
|
||||||
|
*hud_vis = match *hud_vis {
|
||||||
|
HudVisibility::Visible => HudVisibility::Hidden,
|
||||||
|
HudVisibility::Hidden => HudVisibility::Visible,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
tracker.started_on_button = false;
|
||||||
|
}
|
||||||
|
// Moved: don't clear start_pos — Android fires Moved for normal
|
||||||
|
// tap jitter, and the distance check at Ended already rejects
|
||||||
|
// real drags. Clearing here would silently swallow tap toggles.
|
||||||
|
TouchPhase::Canceled => {
|
||||||
|
tracker.start_pos = None;
|
||||||
|
tracker.started_on_button = false;
|
||||||
|
game_consumed.0 = false;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,697 @@
|
|||||||
|
//! Persistent in-game HUD: score, move count, elapsed time, mode badge,
|
||||||
|
//! daily-challenge constraint, and undo count.
|
||||||
|
//!
|
||||||
|
//! The HUD spawns once at startup and lives for the app's lifetime. Text is
|
||||||
|
//! refreshed whenever `GameStateResource` changes (which happens on every move
|
||||||
|
//! and every elapsed-time tick), so score, moves, and timer all stay current
|
||||||
|
//! without a separate tick system.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
|
||||||
|
mod fx;
|
||||||
|
mod interaction;
|
||||||
|
mod spawn;
|
||||||
|
pub(crate) mod tab_bar;
|
||||||
|
mod updates;
|
||||||
|
|
||||||
|
pub use fx::*;
|
||||||
|
use interaction::*;
|
||||||
|
use spawn::*;
|
||||||
|
use tab_bar::*;
|
||||||
|
use updates::*;
|
||||||
|
|
||||||
|
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
||||||
|
// Option<Res<AvatarResource>> parameters below compile without changes.
|
||||||
|
// The resource is never inserted on wasm, so every call resolves to None.
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
#[derive(bevy::prelude::Resource)]
|
||||||
|
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
||||||
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
|
use crate::events::{
|
||||||
|
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||||
|
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
||||||
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
|
};
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use crate::input_plugin::TouchDragSet;
|
||||||
|
use crate::layout::HUD_BAND_HEIGHT;
|
||||||
|
use crate::layout::LayoutSystem;
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::platform::{SHOW_KEYBOARD_ACCELERATORS, USE_TOUCH_UI_LAYOUT};
|
||||||
|
use crate::progress_plugin::ProgressResource;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
use crate::resources::{DragState, GameInputConsumedResource};
|
||||||
|
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
|
||||||
|
use crate::selection_plugin::SelectionState;
|
||||||
|
use crate::settings_plugin::SettingsResource;
|
||||||
|
use crate::time_attack_plugin::TimeAttackResource;
|
||||||
|
use crate::ui_focus::{FocusGroup, Focusable};
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
use crate::ui_theme::SPACE_2;
|
||||||
|
use crate::ui_theme::UiTextFx;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI, BG_ELEVATED_PRESSED,
|
||||||
|
BG_HUD_BAND, BORDER_SUBTLE, HighContrastBorder, MOTION_SCORE_PULSE_SECS,
|
||||||
|
MOTION_STREAK_FLOURISH_SECS, RADIUS_MD, RADIUS_SM, STATE_DANGER, STATE_INFO, STATE_SUCCESS,
|
||||||
|
STATE_WARNING, STREAK_FLOURISH_PEAK_SCALE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY,
|
||||||
|
TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
|
scaled_duration,
|
||||||
|
};
|
||||||
|
use crate::ui_tooltip::Tooltip;
|
||||||
|
use solitaire_data::SyncBackend;
|
||||||
|
|
||||||
|
/// Marker on the score text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudScore;
|
||||||
|
|
||||||
|
/// Marker on the move-count text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudMoves;
|
||||||
|
|
||||||
|
/// Marker on the elapsed-time text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudTime;
|
||||||
|
|
||||||
|
/// Marker on the mode badge text node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudMode;
|
||||||
|
|
||||||
|
/// Marker on the daily-challenge constraint text node.
|
||||||
|
///
|
||||||
|
/// Displays the active goal (time limit or score target) when a daily challenge
|
||||||
|
/// is in progress. Empty string when no challenge is active or the game is won.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudChallenge;
|
||||||
|
|
||||||
|
/// Marker on the "won this deal before" indicator text node.
|
||||||
|
///
|
||||||
|
/// Displays `"✓ Won before"` when the current deal's seed + draw_mode +
|
||||||
|
/// mode triple matches one of the entries in `ReplayHistoryResource`.
|
||||||
|
/// Empty string otherwise (including won games — the score readout
|
||||||
|
/// already conveys the win on the active deal). Only meaningful for
|
||||||
|
/// Classic / Zen / Challenge — daily-challenge and time-attack seeds
|
||||||
|
/// are filtered out implicitly because their replay entries always
|
||||||
|
/// carry a different mode tag.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudWonPreviously;
|
||||||
|
|
||||||
|
/// Marker on the undo-count text node.
|
||||||
|
///
|
||||||
|
/// Shows how many undos have been used this game. Displayed in amber when
|
||||||
|
/// `undo_count > 0` because using undo blocks the no-undo achievement.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudUndos;
|
||||||
|
|
||||||
|
/// Marker on the auto-complete badge text node.
|
||||||
|
///
|
||||||
|
/// Displays `"AUTO"` in green while `AutoCompleteState.active` is true;
|
||||||
|
/// empty string otherwise.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudAutoComplete;
|
||||||
|
|
||||||
|
/// Marker on the stock-recycle counter text node.
|
||||||
|
///
|
||||||
|
/// Displays `"Recycles: N"` whenever `recycle_count > 0`, regardless of draw
|
||||||
|
/// mode, so the player can track stock recycling in both Draw-One and
|
||||||
|
/// Draw-Three (relevant to the `comeback` achievement). Hidden (empty string)
|
||||||
|
/// until the first recycle occurs.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudRecycles;
|
||||||
|
|
||||||
|
/// Marker on the draw-cycle indicator text node.
|
||||||
|
///
|
||||||
|
/// Only shown in Draw-Three mode. Displays `"Cycle: N/3"` where N is the
|
||||||
|
/// number of cards that will be drawn on the next stock click
|
||||||
|
/// (`min(stock_len, 3)`). Shows `"Cycle: 0/3"` when the stock is empty
|
||||||
|
/// (recycle available). Hidden (empty string) in Draw-One mode or after the
|
||||||
|
/// game is won.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudDrawCycle;
|
||||||
|
|
||||||
|
/// Marker on the keyboard-selection indicator text node.
|
||||||
|
///
|
||||||
|
/// Displays `"▶ {pile_name}"` while a pile is selected via Tab, or an empty
|
||||||
|
/// string when no pile is selected. Uses a light-yellow colour so it stands
|
||||||
|
/// out from the other white HUD items.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudSelection;
|
||||||
|
|
||||||
|
/// Marker on the HUD band background node (the translucent band behind buttons).
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudBand;
|
||||||
|
|
||||||
|
/// Marker on the HUD score/info column root node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudColumn;
|
||||||
|
|
||||||
|
/// Marker on the action button bar root node.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudActionBar;
|
||||||
|
|
||||||
|
/// Set wrapping the chained HUD button/popover interaction systems. Other
|
||||||
|
/// keyboard consumers order themselves around it (e.g.
|
||||||
|
/// [`crate::ui_focus::FocusKeys`] runs after) so input-consumption order is
|
||||||
|
/// deterministic (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct HudButtons;
|
||||||
|
|
||||||
|
/// Marker on the text node inside each touch-layout action-bar button.
|
||||||
|
/// Used by `resize_action_bar_labels` to update font size on window resize.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ActionButtonLabel;
|
||||||
|
|
||||||
|
/// Marker on the circular profile-picture button anchored to the
|
||||||
|
/// top-right of the HUD band. Pressing it opens the Profile overlay.
|
||||||
|
/// Shows the server avatar image when loaded; falls back to the player's
|
||||||
|
/// initial on a filled disc when no image is available.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudAvatar;
|
||||||
|
|
||||||
|
/// Controls whether the in-game HUD (band, score column, action buttons) is
|
||||||
|
/// visible. Toggled on Android by tapping empty board space; always `Visible`
|
||||||
|
/// on desktop. Resets to `Visible` whenever a modal opens.
|
||||||
|
#[derive(Resource, Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub enum HudVisibility {
|
||||||
|
#[default]
|
||||||
|
Visible,
|
||||||
|
Hidden,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
struct HudTapTracker {
|
||||||
|
start_pos: Option<Vec2>,
|
||||||
|
/// Set `true` when the finger-down hit an action button so the
|
||||||
|
/// finger-up never toggles bar visibility.
|
||||||
|
started_on_button: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const HUD_TAP_SLOP_PX: f32 = 25.0;
|
||||||
|
|
||||||
|
/// Drives the score-readout pulse: scales the [`HudScore`] text from
|
||||||
|
/// 1.0 → 1.1 → 1.0 over [`MOTION_SCORE_PULSE_SECS`] (scaled by
|
||||||
|
/// [`AnimSpeed`](solitaire_data::AnimSpeed)). Inserted on the score
|
||||||
|
/// entity whenever the score increases; removed once `elapsed >=
|
||||||
|
/// duration`.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ScorePulse {
|
||||||
|
/// Seconds elapsed since the pulse started.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total duration. Zero under `AnimSpeed::Instant` — the system
|
||||||
|
/// snaps the scale back to 1.0 on first tick so no half-state
|
||||||
|
/// is ever shown.
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on a transient floating "+N" text spawned next to the score
|
||||||
|
/// readout when the score jumps by [`SCORE_FLOATER_THRESHOLD`] or more.
|
||||||
|
/// Drifts upward and fades out over `MOTION_SCORE_PULSE_SECS * 2`,
|
||||||
|
/// then despawns. Kept rare/meaningful by the threshold gate.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct ScoreFloater {
|
||||||
|
/// Seconds elapsed since the floater spawned.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total lifetime. Zero under `AnimSpeed::Instant` — the system
|
||||||
|
/// despawns it on first tick.
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drives the streak-milestone flourish: scales the [`HudScore`] text
|
||||||
|
/// from `1.0 → STREAK_FLOURISH_PEAK_SCALE → 1.0` over
|
||||||
|
/// [`MOTION_STREAK_FLOURISH_SECS`] (scaled by
|
||||||
|
/// [`AnimSpeed`](solitaire_data::AnimSpeed)) and tints it
|
||||||
|
/// [`ACCENT_SECONDARY`] for the same window before restoring the
|
||||||
|
/// original colour.
|
||||||
|
///
|
||||||
|
/// The streak readout currently lives in the Stats overlay (press
|
||||||
|
/// `S`) — there is no always-on HUD streak counter — so the flourish
|
||||||
|
/// piggybacks on the score readout, which is the most prominent
|
||||||
|
/// always-visible HUD number. Mirrors the `FoundationFlourish`
|
||||||
|
/// pattern: triangular scale curve, fixed duration, restores state
|
||||||
|
/// when the timer expires.
|
||||||
|
///
|
||||||
|
/// Inserted on `HudScore` entities by `start_streak_flourish` when a
|
||||||
|
/// `WinStreakMilestoneEvent` fires; removed once `elapsed >=
|
||||||
|
/// duration` so the readout returns to its rest state for the next
|
||||||
|
/// frame's transform sync.
|
||||||
|
///
|
||||||
|
/// Coexists with [`ScorePulse`]: the streak flourish lives on a
|
||||||
|
/// dedicated marker so a streak-crossing win that also ticks the
|
||||||
|
/// score (every win does) doesn't have the two animations stomp on
|
||||||
|
/// each other's `Transform.scale` writes — the streak flourish runs
|
||||||
|
/// in a `Without<ScorePulse>` query so only the loudest of the two
|
||||||
|
/// celebrations is active at a time.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub struct StreakFlourish {
|
||||||
|
/// The streak milestone that triggered this flourish (3, 5, 10).
|
||||||
|
/// Carried for diagnostic logging only — the visual is identical
|
||||||
|
/// for every threshold so play-testing can decide later whether
|
||||||
|
/// to differentiate.
|
||||||
|
pub streak: u32,
|
||||||
|
/// Seconds elapsed since the flourish began.
|
||||||
|
pub elapsed: f32,
|
||||||
|
/// Total animation length in seconds. Zero under
|
||||||
|
/// [`AnimSpeed::Instant`](solitaire_data::AnimSpeed) — the system
|
||||||
|
/// snaps the scale back to 1.0 on the first tick so no half-state
|
||||||
|
/// is ever shown.
|
||||||
|
pub duration: f32,
|
||||||
|
/// The score readout's colour before the flourish began —
|
||||||
|
/// restored when the timer expires so the readout returns to its
|
||||||
|
/// resting `TEXT_PRIMARY` (or whatever it was) tint.
|
||||||
|
pub original_color: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tracks the score from the previous frame so the HUD can detect
|
||||||
|
/// changes without a `ScoreChangedEvent`. The plugin wires this to the
|
||||||
|
/// pulse + floater systems on every `Update`.
|
||||||
|
#[derive(Resource, Debug, Default, Clone, Copy)]
|
||||||
|
pub struct PreviousScore(pub i32);
|
||||||
|
|
||||||
|
/// Score increase (in points) below which no floating "+N" is spawned.
|
||||||
|
/// 50 keeps the feedback for foundation drops and tableau-to-foundation
|
||||||
|
/// promotions; single-card placements (which can earn as little as +5)
|
||||||
|
/// stay quiet so the floater feels like a reward instead of noise.
|
||||||
|
pub const SCORE_FLOATER_THRESHOLD: i32 = 50;
|
||||||
|
|
||||||
|
/// Marker shared by every clickable HUD action button so a single
|
||||||
|
/// `paint_action_buttons` system can recolour them on hover/press without
|
||||||
|
/// each button needing its own paint handler.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ActionButton;
|
||||||
|
|
||||||
|
/// Marker on rows inside a popover panel ([`ModesPopover`] or
|
||||||
|
/// [`MenuPopover`]). Popover rows already carry `ActionButton` so the
|
||||||
|
/// hover/press paint path applies to them, but the auto-fade applied
|
||||||
|
/// to the top-level action bar must NOT also fade these rows — the
|
||||||
|
/// popover only renders when the player has explicitly opened it, so
|
||||||
|
/// its content should always be at full opacity. `apply_action_fade`
|
||||||
|
/// excludes entities with this marker via `Without<PopoverRow>`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct PopoverRow;
|
||||||
|
|
||||||
|
/// Marker on the "New Game" action button anchored top-right of the play
|
||||||
|
/// area. Click fires [`NewGameRequestEvent`]; the existing
|
||||||
|
/// `ConfirmNewGameScreen` modal handles confirmation when a game is in
|
||||||
|
/// progress.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct NewGameButton;
|
||||||
|
|
||||||
|
/// Marker on the "Undo" action button. Click fires [`UndoRequestEvent`],
|
||||||
|
/// mirroring the `U` keyboard accelerator.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct UndoButton;
|
||||||
|
|
||||||
|
/// Marker on the "Pause" action button. Click fires [`PauseRequestEvent`],
|
||||||
|
/// mirroring the `Esc` keyboard accelerator. The pause overlay's own resume
|
||||||
|
/// affordance dismisses it from the paused state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct PauseButton;
|
||||||
|
|
||||||
|
/// Accumulated hold time on the Undo button, driving hold-to-repeat undo
|
||||||
|
/// (Phase F). `handle_undo_button` owns the instant first undo on press;
|
||||||
|
/// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s
|
||||||
|
/// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every
|
||||||
|
/// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the
|
||||||
|
/// normal request path, so the existing scoring penalty applies per step.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct UndoHoldState {
|
||||||
|
/// Seconds the Undo button has been continuously held.
|
||||||
|
held_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
||||||
|
/// mirroring the `F1` keyboard accelerator.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HelpButton;
|
||||||
|
|
||||||
|
/// Marker on the "Hint" action button. Click spawns an async solver task
|
||||||
|
/// (same as the `H` keyboard accelerator) and highlights the suggested card.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HintButton;
|
||||||
|
|
||||||
|
/// Marker on the touch action bar's "Draw" button (Phase F). Click fires
|
||||||
|
/// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the
|
||||||
|
/// draw action is within thumb reach without stretching to the top of a
|
||||||
|
/// tall folded screen. Touch layout only; desktop draws via stock
|
||||||
|
/// click / `D` / `Space`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct DrawButton;
|
||||||
|
|
||||||
|
/// Android HUD label for the Hint button — shared with the help screen's
|
||||||
|
/// controls reference so both always agree.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
||||||
|
|
||||||
|
/// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`]
|
||||||
|
/// on Android so the bar and the help screen's controls reference can
|
||||||
|
/// never drift; the non-Android value only exists so the touch spawn
|
||||||
|
/// path stays host-compilable for tests.
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const TOUCH_HINT_LABEL: &str = "Hint";
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_BAR_COLUMN_GAP: Val = VAL_SPACE_2;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const ACTION_POPOVER_BOTTOM_PX: f32 = 200.0;
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const ACTION_POPOVER_BOTTOM_PX: f32 = 80.0;
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const HINT_WON_MSG: &str = "Game won! Tap New Game to play again";
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const HINT_WON_MSG: &str = "Game won! Press N for a new game";
|
||||||
|
|
||||||
|
/// Marker on the "Modes" action button. Click toggles the [`ModesPopover`]
|
||||||
|
/// (a small dropdown panel) below the action bar. Each popover row starts
|
||||||
|
/// the corresponding game mode.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ModesButton;
|
||||||
|
|
||||||
|
/// Marker on the dropdown panel that opens below the [`ModesButton`].
|
||||||
|
/// Spawned on first click, despawned on second click or on mode select.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ModesPopover;
|
||||||
|
|
||||||
|
/// One row inside the [`ModesPopover`]. The variant carries which event
|
||||||
|
/// the click handler should fire — Classic uses `NewGameRequestEvent`
|
||||||
|
/// directly, the others go through their `Start*RequestEvent` so the
|
||||||
|
/// existing keyboard handler's level gate / resource setup runs.
|
||||||
|
#[derive(Component, Debug, Clone, Copy)]
|
||||||
|
pub enum ModeOption {
|
||||||
|
Classic,
|
||||||
|
DailyChallenge,
|
||||||
|
Zen,
|
||||||
|
Challenge,
|
||||||
|
TimeAttack,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
|
||||||
|
/// which exposes the Home / Profile / Stats / Achievements /
|
||||||
|
/// Leaderboard / Settings / Help overlays without needing the
|
||||||
|
/// M/P/S/A/L/O/F1 hotkeys.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct MenuButton;
|
||||||
|
|
||||||
|
/// Marker on the dropdown panel that opens below the [`MenuButton`].
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct MenuPopover;
|
||||||
|
|
||||||
|
/// Shared marker placed on both [`MenuPopover`] and [`ModesPopover`] entities
|
||||||
|
/// while they are open. External systems (e.g. `PausePlugin`) query this to
|
||||||
|
/// determine whether a HUD popover is currently visible without importing the
|
||||||
|
/// individual popover types.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HudPopoverOpen;
|
||||||
|
|
||||||
|
/// Fullscreen transparent backdrop spawned behind the [`MenuPopover`].
|
||||||
|
/// Pressing it (tap anywhere outside the popover) light-dismisses the menu.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct MenuPopoverBackdrop;
|
||||||
|
|
||||||
|
/// Fullscreen transparent backdrop spawned behind the [`ModesPopover`].
|
||||||
|
/// Pressing it (tap anywhere outside the popover) light-dismisses it.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ModesPopoverBackdrop;
|
||||||
|
|
||||||
|
/// One row inside the [`MenuPopover`]. The variant selects which
|
||||||
|
/// `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)]
|
||||||
|
pub enum MenuOption {
|
||||||
|
Home,
|
||||||
|
Profile,
|
||||||
|
Stats,
|
||||||
|
Achievements,
|
||||||
|
Leaderboard,
|
||||||
|
Settings,
|
||||||
|
Help,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
|
||||||
|
/// Mirrors `ui_theme::Z_HUD` and is duplicated here only so the hud module
|
||||||
|
/// can use it as a `const` without a non-const expression in `ZIndex(...)`.
|
||||||
|
const Z_HUD: i32 = crate::ui_theme::Z_HUD;
|
||||||
|
const Z_HUD_POPOVER_BACKDROP: i32 = crate::ui_theme::Z_HUD_POPOVER_BACKDROP;
|
||||||
|
const Z_HUD_POPOVER: i32 = crate::ui_theme::Z_HUD_POPOVER;
|
||||||
|
const Z_HUD_TOP: i32 = crate::ui_theme::Z_HUD_TOP;
|
||||||
|
|
||||||
|
/// Idle / hover / pressed colours shared by every action button. Aliased
|
||||||
|
/// to the theme tokens so the HUD picks up palette changes for free.
|
||||||
|
const ACTION_BTN_IDLE: Color = BG_ELEVATED;
|
||||||
|
const ACTION_BTN_HOVER: Color = BG_ELEVATED_HI;
|
||||||
|
const ACTION_BTN_PRESSED: Color = BG_ELEVATED_PRESSED;
|
||||||
|
|
||||||
|
/// Renders the in-game HUD: score counter, move counter, elapsed timer, draw-mode indicator, and the auto-complete badge that lights up when the game is solvable without further input.
|
||||||
|
pub struct HudPlugin;
|
||||||
|
|
||||||
|
impl Plugin for HudPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
// The click handlers write to messages registered elsewhere by their
|
||||||
|
// owning plugins (`GamePlugin`, `PausePlugin`, `HelpPlugin`,
|
||||||
|
// `challenge_plugin`, `daily_challenge_plugin`, `time_attack_plugin`,
|
||||||
|
// `input_plugin`). Re-register defensively so the HUD plugin works in
|
||||||
|
// isolation under `MinimalPlugins` (tests). `add_message` is
|
||||||
|
// idempotent.
|
||||||
|
app.add_message::<NewGameRequestEvent>()
|
||||||
|
.add_message::<UndoRequestEvent>()
|
||||||
|
.add_message::<DrawRequestEvent>()
|
||||||
|
.add_message::<PauseRequestEvent>()
|
||||||
|
.add_message::<HelpRequestEvent>()
|
||||||
|
.add_message::<StartZenRequestEvent>()
|
||||||
|
.add_message::<StartChallengeRequestEvent>()
|
||||||
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
|
.add_message::<ToggleStatsRequestEvent>()
|
||||||
|
.add_message::<ToggleHomeRequestEvent>()
|
||||||
|
.add_message::<ToggleAchievementsRequestEvent>()
|
||||||
|
.add_message::<ToggleProfileRequestEvent>()
|
||||||
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
|
.add_message::<ToggleLeaderboardRequestEvent>()
|
||||||
|
.add_message::<WinStreakMilestoneEvent>()
|
||||||
|
.init_resource::<PreviousScore>()
|
||||||
|
.init_resource::<HudActionFade>()
|
||||||
|
.init_resource::<HudVisibility>()
|
||||||
|
.init_resource::<UndoHoldState>()
|
||||||
|
// Escape-close handlers for popovers read this; init defensively
|
||||||
|
// so HudPlugin works under MinimalPlugins in tests.
|
||||||
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
|
// WindowResized is registered by table_plugin; re-register
|
||||||
|
// defensively so the HUD plugin works standalone in tests.
|
||||||
|
.add_message::<WindowResized>()
|
||||||
|
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
||||||
|
// HUD text updaters run as one deterministic chain (they write
|
||||||
|
// disjoint Text nodes, but Bevy can't prove it); update_hud also
|
||||||
|
// reads AutoCompleteState, so the chain sits after the
|
||||||
|
// auto-complete detect/drive chain (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
update_hud,
|
||||||
|
update_selection_hud.run_if(
|
||||||
|
resource_exists_and_changed::<SelectionState>
|
||||||
|
.or(resource_exists_and_changed::<GameStateResource>),
|
||||||
|
),
|
||||||
|
update_won_previously,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::auto_complete_plugin::AutoComplete)
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx),
|
||||||
|
)
|
||||||
|
// HUD chrome visibility: modal-restore writes HudVisibility, the
|
||||||
|
// applier consumes it, and the layout recompute reads it — a
|
||||||
|
// fixed chain instead of three racing systems (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(restore_hud_on_modal, apply_hud_visibility)
|
||||||
|
.chain()
|
||||||
|
.before(LayoutSystem::UpdateOnResize)
|
||||||
|
// Toggles Visibility on HUD chrome roots only — joins
|
||||||
|
// the chrome club (#143).
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
update_hud_avatar
|
||||||
|
// Same-frame-exact after the settings spine, so NOT
|
||||||
|
// a SettingsAccess member — but the club's readers
|
||||||
|
// and the home deal-option writers touch settings
|
||||||
|
// fields the avatar never reads (#143).
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess)
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
// Two more settings writers arrived with the wider
|
||||||
|
// ambiguity cluster: the pause overlay's draw-mode
|
||||||
|
// toggle and the achievement onboarding flag (#143).
|
||||||
|
.after(crate::pause_plugin::PauseMutation)
|
||||||
|
.after(crate::game_plugin::InfoToastWriters),
|
||||||
|
handle_avatar_button
|
||||||
|
.ambiguous_with(HudButtons)
|
||||||
|
.in_set(crate::profile_plugin::ProfileToggleWriters)
|
||||||
|
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
announce_auto_complete
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::auto_complete_plugin::AutoComplete)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// Typography rescale touches HUD TextFont only, but orders after
|
||||||
|
// the board painters that resize card/label text (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
update_hud_typography
|
||||||
|
.after(crate::card_plugin::BoardVisuals)
|
||||||
|
// Writes HUD TextFont only — chrome club (#143).
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
detect_score_change,
|
||||||
|
advance_score_pulse,
|
||||||
|
advance_score_floater,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation)
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
// Reads only the reduce-motion flag; a frame of lag is
|
||||||
|
// invisible (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(start_streak_flourish, advance_streak_flourish)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation)
|
||||||
|
// Reads the streak-milestone events StatsUpdate emits.
|
||||||
|
.after(crate::stats_plugin::StatsUpdate)
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx)
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
// Reads only the reduce-motion flag; a frame of lag is
|
||||||
|
// invisible (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_new_game_button
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
handle_undo_button
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
|
repeat_undo_on_hold
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
|
handle_draw_button
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
|
handle_pause_button,
|
||||||
|
handle_help_button,
|
||||||
|
handle_hint_button
|
||||||
|
.after(GameMutation)
|
||||||
|
// A press cancels/spawns the solver task before
|
||||||
|
// this frame's drop/poll pass (#143).
|
||||||
|
.before(crate::pending_hint::PendingHint)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
// The modes/menu popovers repaint the player level from
|
||||||
|
// ProgressResource — read it after this frame's XP award
|
||||||
|
// settles (#143).
|
||||||
|
handle_modes_button
|
||||||
|
.after(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.after(crate::achievement_plugin::AchievementEval),
|
||||||
|
handle_mode_option_click
|
||||||
|
.in_set(NewGameRequestWriters)
|
||||||
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
|
handle_modes_backdrop_click,
|
||||||
|
close_modes_popover_on_escape,
|
||||||
|
handle_menu_button,
|
||||||
|
handle_menu_option_click
|
||||||
|
.after(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.after(crate::achievement_plugin::AchievementEval)
|
||||||
|
.in_set(crate::profile_plugin::ProfileToggleWriters)
|
||||||
|
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
|
||||||
|
handle_menu_backdrop_click,
|
||||||
|
close_menu_popover_on_escape,
|
||||||
|
paint_action_buttons,
|
||||||
|
paint_glass_tab_buttons,
|
||||||
|
drive_tab_expansion,
|
||||||
|
// Writes tab-label TextColor and reads TextFont — entity-
|
||||||
|
// disjoint from the UiTextFx readout writers (tab labels
|
||||||
|
// vs HUD readouts), which Bevy can't prove, so declare it.
|
||||||
|
animate_tab_expansion
|
||||||
|
.ambiguous_with(UiTextFx)
|
||||||
|
// Tab-label fonts are never card/corner-label fonts.
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
// Reads only reduce-motion; a frame of lag is
|
||||||
|
// invisible, so it joins the settings-reader club.
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
// Reads only reduce-motion — settings-reader club (#143).
|
||||||
|
duck_tab_bar_during_drag
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(HudButtons)
|
||||||
|
.before(crate::ui_focus::FocusKeys),
|
||||||
|
)
|
||||||
|
// Fade lives in `Last` so it always overrides whatever the
|
||||||
|
// hover/paint pass set on `BackgroundColor` this frame.
|
||||||
|
// Otherwise on a hover-state change (`Changed<Interaction>`),
|
||||||
|
// `paint_action_buttons` would clobber the alpha back to 1.0
|
||||||
|
// mid-fade and produce a visible blip.
|
||||||
|
;
|
||||||
|
// Desktop-only: cursor-proximity fade. On Android the bar
|
||||||
|
// visibility is toggled explicitly; cursor_position() returning
|
||||||
|
// Some(touch_pos) during a tap would otherwise fade the bar out.
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
app.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
{
|
||||||
|
app.init_resource::<HudTapTracker>()
|
||||||
|
.add_message::<TouchInput>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
toggle_hud_on_tap
|
||||||
|
.after(TouchDragSet::AfterStartDrag)
|
||||||
|
.in_set(TouchDragSet::BeforeEndDrag),
|
||||||
|
);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
resize_action_bar_labels
|
||||||
|
.run_if(resource_exists_and_changed::<crate::layout::LayoutResource>),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
//! HUD construction: band, columns, avatar, and action-bar spawning.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
use crate::avatar_plugin::AvatarResource;
|
||||||
|
|
||||||
|
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
||||||
|
/// the screen so the card layout (computed by `layout::compute_layout` using
|
||||||
|
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
||||||
|
///
|
||||||
|
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
||||||
|
/// A slim grey background is handled by each content section individually
|
||||||
|
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
||||||
|
pub(super) fn spawn_hud_band(mut commands: Commands) {
|
||||||
|
const BASE_TOP: f32 = 0.0;
|
||||||
|
commands.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(BASE_TOP),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
height: Val::Px(HUD_BAND_HEIGHT),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD - 1),
|
||||||
|
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||||
|
HudBand,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the in-game HUD as a 4-tier vertical column anchored to the
|
||||||
|
/// top-left of the play area.
|
||||||
|
///
|
||||||
|
/// Tiers (top to bottom):
|
||||||
|
/// 1. **Primary** — Score (display weight) · Moves · Timer.
|
||||||
|
/// Always visible during gameplay.
|
||||||
|
/// 2. **Mode context** — Mode badge · Daily-challenge constraint ·
|
||||||
|
/// Draw-cycle indicator. Each cell is empty when not relevant; the
|
||||||
|
/// row collapses visually when all cells are empty.
|
||||||
|
/// 3. **Penalty / bonus** — Undos · Recycles · Auto-complete badge.
|
||||||
|
/// Both penalty counters share `STATE_WARNING` (the audit found
|
||||||
|
/// they were inconsistent: Undos amber, Recycles white).
|
||||||
|
/// 4. **Selection** — keyboard-driven pile selector chip.
|
||||||
|
///
|
||||||
|
/// The audit identified the original single-row layout (10 readouts in
|
||||||
|
/// one horizontal flex row, 5+ colour families competing) as the
|
||||||
|
/// player's #1 complaint. This restructure groups by purpose, lets
|
||||||
|
/// transient items disappear cleanly, and uses the typography scale to
|
||||||
|
/// make Score the visual protagonist.
|
||||||
|
pub(super) fn spawn_hud(font_res: Option<Res<FontResource>>, mut commands: Commands) {
|
||||||
|
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_score = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_HEADLINE,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_lg = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_body = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
let row_node = || Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
// On a narrow viewport the four tier rows (Score/Moves/Timer,
|
||||||
|
// Mode/Challenge/Draw-cycle/Won-previously, Undos/Recycles/
|
||||||
|
// Auto-complete, selection chip) can collectively be wider than
|
||||||
|
// the available space and overflow into the action-button column
|
||||||
|
// on the right. `flex_wrap: Wrap` lets each tier soft-wrap onto
|
||||||
|
// a second line; on a desktop window the rows stay single-line
|
||||||
|
// because the parent column has no width cap and the row never
|
||||||
|
// exceeds the natural line width.
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
align_items: AlignItems::Baseline,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
top: Val::Px(SPACE_2),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
// Cap the column at 50% of viewport so on narrow
|
||||||
|
// (mobile) widths the inner tier rows have a bounded
|
||||||
|
// width to wrap against, and the column can't bleed
|
||||||
|
// into the right-anchored action button row (also
|
||||||
|
// capped at 50%). On desktop 50% of 1920 = 960 px,
|
||||||
|
// wider than any tier row's natural width, so the
|
||||||
|
// visible layout is unaffected.
|
||||||
|
max_width: Val::Percent(50.0),
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
|
HudColumn,
|
||||||
|
))
|
||||||
|
.with_children(|hud| {
|
||||||
|
// Tier 1 — primary readouts. Score is the protagonist (HEADLINE);
|
||||||
|
// Moves and Timer are supporting context (BODY_LG, secondary tone).
|
||||||
|
hud.spawn(row_node()).with_children(|t1| {
|
||||||
|
t1.spawn((
|
||||||
|
HudScore,
|
||||||
|
Tooltip::new("Points earned this game. Hidden in Zen mode."),
|
||||||
|
Text::new("Score: 0"),
|
||||||
|
font_score.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
t1.spawn((
|
||||||
|
HudMoves,
|
||||||
|
Tooltip::new("Moves you've made this game. Counts placements and stock draws."),
|
||||||
|
Text::new("Moves: 0"),
|
||||||
|
font_lg.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
t1.spawn((
|
||||||
|
HudTime,
|
||||||
|
Tooltip::new("Time on this game. Counts down in Time Attack."),
|
||||||
|
Text::new("0:00"),
|
||||||
|
font_lg.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 2 — mode context. Each cell is empty until update_hud
|
||||||
|
// populates it (and clears it when no longer relevant), so the
|
||||||
|
// row collapses when nothing in this tier applies.
|
||||||
|
hud.spawn(row_node()).with_children(|t2| {
|
||||||
|
t2.spawn((
|
||||||
|
HudMode,
|
||||||
|
Tooltip::new("Active game mode. Click Modes to switch."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudChallenge,
|
||||||
|
Tooltip::new("Today's daily challenge target. Beat it for bonus XP."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_INFO),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudDrawCycle,
|
||||||
|
Tooltip::new("Cards drawn on the next stock click in Draw-Three."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_INFO),
|
||||||
|
));
|
||||||
|
t2.spawn((
|
||||||
|
HudWonPreviously,
|
||||||
|
Tooltip::new("You've won this deal before. Same seed in your replay history."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_SUCCESS),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 3 — penalty / bonus. Undos and Recycles share the
|
||||||
|
// warning hue so they read as the same category ("you took a
|
||||||
|
// penalty"); the auto-complete badge stays success-green.
|
||||||
|
hud.spawn(row_node()).with_children(|t3| {
|
||||||
|
t3.spawn((
|
||||||
|
HudUndos,
|
||||||
|
Tooltip::new("Undos used this game. Any undo blocks the No Undo achievement."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_WARNING),
|
||||||
|
));
|
||||||
|
t3.spawn((
|
||||||
|
HudRecycles,
|
||||||
|
Tooltip::new(
|
||||||
|
"Times you've recycled the stock. Three or more unlocks Comeback.",
|
||||||
|
),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_WARNING),
|
||||||
|
));
|
||||||
|
t3.spawn((
|
||||||
|
HudAutoComplete,
|
||||||
|
Tooltip::new("Board is solvable from here. Press Enter to auto-finish."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body.clone(),
|
||||||
|
TextColor(STATE_SUCCESS),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Tier 4 — selection chip. Stays in HUD for now; a future
|
||||||
|
// pass can reposition it next to the selected pile.
|
||||||
|
hud.spawn(row_node()).with_children(|t4| {
|
||||||
|
t4.spawn((
|
||||||
|
HudSelection,
|
||||||
|
Tooltip::new("Pile selected with Tab. Use arrows or Enter to act."),
|
||||||
|
Text::new(""),
|
||||||
|
font_body,
|
||||||
|
TextColor(ACCENT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the circular avatar / initials button anchored to the top-right
|
||||||
|
/// of the HUD band. Initial content is seeded from whatever resources are
|
||||||
|
/// available at startup; `update_hud_avatar` replaces the children whenever
|
||||||
|
/// `AvatarResource` or `SettingsResource` later changes.
|
||||||
|
pub(super) fn spawn_hud_avatar(
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
avatar: Option<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
const SIZE: f32 = 32.0;
|
||||||
|
let id = commands
|
||||||
|
.spawn((
|
||||||
|
HudAvatar,
|
||||||
|
Button,
|
||||||
|
Tooltip::new("Your profile — tap to open."),
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
top: Val::Px(SPACE_2),
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
width: Val::Px(SIZE),
|
||||||
|
height: Val::Px(SIZE),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACCENT_PRIMARY),
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
spawn_avatar_child(
|
||||||
|
&mut commands,
|
||||||
|
id,
|
||||||
|
avatar.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-spawns the avatar circle content (image or initials) whenever either
|
||||||
|
/// [`AvatarResource`] or [`SettingsResource`] changes — covers both the
|
||||||
|
/// image arriving after download and the username changing after login.
|
||||||
|
pub(super) fn update_hud_avatar(
|
||||||
|
avatar: Option<Res<AvatarResource>>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
q: Query<Entity, With<HudAvatar>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let avatar_changed = avatar.as_ref().is_some_and(|r| r.is_changed());
|
||||||
|
let settings_changed = settings.as_ref().is_some_and(|r| r.is_changed());
|
||||||
|
if !avatar_changed && !settings_changed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Ok(entity) = q.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
commands.entity(entity).despawn_related::<Children>();
|
||||||
|
spawn_avatar_child(
|
||||||
|
&mut commands,
|
||||||
|
entity,
|
||||||
|
avatar.as_deref(),
|
||||||
|
settings.as_deref(),
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Populates the avatar container with either the downloaded image or an
|
||||||
|
/// initials fallback disc. Called from both the startup spawn and the
|
||||||
|
/// reactive update system so the rendering logic lives in one place.
|
||||||
|
pub(super) fn spawn_avatar_child(
|
||||||
|
commands: &mut Commands,
|
||||||
|
parent: Entity,
|
||||||
|
avatar: Option<&AvatarResource>,
|
||||||
|
settings: Option<&SettingsResource>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
const SIZE: f32 = 32.0;
|
||||||
|
if let Some(handle) = avatar.and_then(|a| a.0.clone()) {
|
||||||
|
// Logged-in with a downloaded avatar: keep the accent disc behind it.
|
||||||
|
commands
|
||||||
|
.entity(parent)
|
||||||
|
.insert(BackgroundColor(ACCENT_PRIMARY));
|
||||||
|
// Image fills the circle container; border_radius clips it to a disc.
|
||||||
|
commands.entity(parent).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
ImageNode::new(handle),
|
||||||
|
Node {
|
||||||
|
width: Val::Px(SIZE),
|
||||||
|
height: Val::Px(SIZE),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(SIZE / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let initial = settings
|
||||||
|
.and_then(|s| match &s.0.sync_backend {
|
||||||
|
SyncBackend::SolitaireServer { username, .. } => username.chars().next(),
|
||||||
|
SyncBackend::Local => None,
|
||||||
|
})
|
||||||
|
.and_then(|c| c.to_uppercase().next())
|
||||||
|
.unwrap_or('?');
|
||||||
|
// Real initial (logged in) keeps the red accent disc; the '?'
|
||||||
|
// unauthenticated fallback uses a neutral grey so it reads as a
|
||||||
|
// "tap to log in" affordance rather than an error.
|
||||||
|
let disc_bg = if initial == '?' {
|
||||||
|
BG_ELEVATED_HI
|
||||||
|
} else {
|
||||||
|
ACCENT_PRIMARY
|
||||||
|
};
|
||||||
|
commands.entity(parent).insert(BackgroundColor(disc_bg));
|
||||||
|
commands.entity(parent).with_children(|b| {
|
||||||
|
b.spawn((
|
||||||
|
Text::new(initial.to_string()),
|
||||||
|
TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: 14.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the Profile overlay when the avatar button is pressed.
|
||||||
|
pub(super) fn handle_avatar_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<HudAvatar>, Changed<Interaction>)>,
|
||||||
|
mut toggle_profile: MessageWriter<ToggleProfileRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
toggle_profile.write(ToggleProfileRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the action button bar anchored to the top-right of the window.
|
||||||
|
/// Each child is a clickable button mirroring a keyboard accelerator —
|
||||||
|
/// per the UI-first principle (CLAUDE.md / ARCHITECTURE.md §1) the buttons
|
||||||
|
/// are the primary entry point and the hotkeys are optional.
|
||||||
|
///
|
||||||
|
/// Order (left → right): Undo, Pause, Help, New Game. New Game is rightmost
|
||||||
|
/// because it's the most consequential action; the destructive button sits
|
||||||
|
/// on its own visual edge.
|
||||||
|
pub(super) fn spawn_action_buttons(
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
images: Option<ResMut<Assets<Image>>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
let action_font_size =
|
||||||
|
action_bar_font_size(windows.iter().next().map_or(900.0, |win| win.width()));
|
||||||
|
let font = TextFont {
|
||||||
|
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: action_font_size,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Touch layout: the floating glass tab bar (pill + circular Menu).
|
||||||
|
// `Assets<Image>` is absent under `MinimalPlugins`; `rasterize_into`
|
||||||
|
// returning `None` switches the bar to its text-fallback form.
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs));
|
||||||
|
spawn_glass_tab_bar(&mut commands, &font, icons.as_ref());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop bottom bar: full-width, centered, docked to the bottom edge.
|
||||||
|
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||||
|
// the platform reports it; initial value is 0.0.
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(0.0),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
column_gap: ACTION_BAR_COLUMN_GAP,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
padding: UiRect {
|
||||||
|
left: VAL_SPACE_3,
|
||||||
|
right: VAL_SPACE_3,
|
||||||
|
top: VAL_SPACE_2,
|
||||||
|
bottom: VAL_SPACE_2,
|
||||||
|
},
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_HUD_BAND),
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
||||||
|
HudActionBar,
|
||||||
|
))
|
||||||
|
.with_children(|row| {
|
||||||
|
spawn_desktop_action_bar(row, &font);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
|
||||||
|
/// is touch-only). All seven actions, uniform sizing.
|
||||||
|
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||||||
|
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||||
|
// so Tab cycles the action bar in visual reading order.
|
||||||
|
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||||
|
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
MenuButton,
|
||||||
|
"Menu \u{2193}",
|
||||||
|
None,
|
||||||
|
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
font,
|
||||||
|
0,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
UndoButton,
|
||||||
|
"Undo",
|
||||||
|
Some("U"),
|
||||||
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||||
|
font,
|
||||||
|
1,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
PauseButton,
|
||||||
|
"Pause",
|
||||||
|
Some("Esc"),
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
font,
|
||||||
|
2,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HelpButton,
|
||||||
|
"Help",
|
||||||
|
Some("F1"),
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
font,
|
||||||
|
3,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HintButton,
|
||||||
|
"Hint",
|
||||||
|
Some("H"),
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
font,
|
||||||
|
4,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
ModesButton,
|
||||||
|
"Modes \u{2193}",
|
||||||
|
None,
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
font,
|
||||||
|
5,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
NewGameButton,
|
||||||
|
"New Game",
|
||||||
|
Some("N"),
|
||||||
|
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||||
|
font,
|
||||||
|
6,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns a single action button as a child of `row`. Each button shares
|
||||||
|
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||||
|
/// `paint_action_buttons` can recolour all of them with one query.
|
||||||
|
///
|
||||||
|
/// `order` is the button's index inside the action bar (0 for the
|
||||||
|
/// leftmost). It propagates into the [`Focusable`] this function inserts
|
||||||
|
/// so Phase 2's keyboard focus ring cycles the HUD in visual order.
|
||||||
|
///
|
||||||
|
/// `tooltip` is the hover-reveal caption attached via [`Tooltip`]. Every
|
||||||
|
/// action button ships with one — there is no opt-out — because each button
|
||||||
|
/// represents a player-triggered action and benefits from a one-line
|
||||||
|
/// reminder of what it does.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn spawn_action_button<M: Component>(
|
||||||
|
row: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
hotkey: Option<&'static str>,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
text_color: Color,
|
||||||
|
) {
|
||||||
|
// Hotkey hint chips ("U", "Esc", "F1", "N") are meaningless on a
|
||||||
|
// touch device — the button itself is the affordance — and they
|
||||||
|
// visibly clutter the narrow-viewport action row. The chevrons on
|
||||||
|
// Menu/Modes remain because they indicate dropdown behaviour.
|
||||||
|
let hotkey = if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
hotkey
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let (pad, min_w, min_h) = action_button_metrics();
|
||||||
|
spawn_action_button_sized(
|
||||||
|
row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared body of [`spawn_action_button`] — one place owns the component
|
||||||
|
/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
|
||||||
|
/// bar button identically.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_action_button_sized<M: Component>(
|
||||||
|
row: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
hotkey: Option<&'static str>,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
text_color: Color,
|
||||||
|
pad: UiRect,
|
||||||
|
min_w: Val,
|
||||||
|
min_h: Val,
|
||||||
|
) {
|
||||||
|
let hotkey_font = TextFont {
|
||||||
|
font: font.font.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
ActionButton,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Focusable {
|
||||||
|
group: FocusGroup::Hud,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
padding: pad,
|
||||||
|
min_width: min_w,
|
||||||
|
min_height: min_h,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
column_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(ACTION_BTN_IDLE),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
spawn_action_button_label(b, label, font, text_color);
|
||||||
|
if let Some(key) = hotkey {
|
||||||
|
// Hotkey hint rendered as a dim caption next to the label —
|
||||||
|
// keeps the keyboard accelerator discoverable without
|
||||||
|
// hijacking the button's primary affordance.
|
||||||
|
b.spawn((Text::new(key), hotkey_font, TextColor(TEXT_SECONDARY)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,529 @@
|
|||||||
|
//! Floating "liquid glass" touch tab bar — layout and animation.
|
||||||
|
//!
|
||||||
|
//! Replaces the docked full-width touch action bar (Phase F) with an
|
||||||
|
//! iOS-26-style floating arrangement: a pill of icon buttons hovering
|
||||||
|
//! above the bottom edge, plus a detached circular Menu button to its
|
||||||
|
//! right. The *visual treatment* (translucent sheen, specular rim, drop
|
||||||
|
//! shadow) lives in [`crate::ui_glass`]; this module owns only geometry,
|
||||||
|
//! button wiring, and the expand/collapse label animation, so the glass
|
||||||
|
//! material can be swapped without touching layout.
|
||||||
|
//!
|
||||||
|
//! Interaction compatibility: every button keeps the same marker
|
||||||
|
//! components as the old bar (`UndoButton`, `DrawButton`, …) plus
|
||||||
|
//! `ActionButton`, `Button`, `Tooltip`, and `Focusable`, so the click
|
||||||
|
//! handlers, tooltips, focus ring, and the tap-to-toggle chrome guard in
|
||||||
|
//! `interaction.rs` all work unchanged.
|
||||||
|
//!
|
||||||
|
//! Expansion model (user decision, 2026-07-15): **Draw** — the most
|
||||||
|
//! frequent action — is the persistently-expanded "active" pill showing
|
||||||
|
//! icon + label; the other buttons are icon-only and reveal their label
|
||||||
|
//! with a width-animated slide while pressed.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::assets::hud_icon_svg::HudIcon;
|
||||||
|
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
|
||||||
|
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
|
||||||
|
use bevy::text::{LineBreak, TextLayout};
|
||||||
|
use bevy::ui::{Overflow, UiTransform, Val2};
|
||||||
|
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use crate::safe_area::SafeAreaInsets;
|
||||||
|
|
||||||
|
/// Height of both the pill and the circular Menu button, in logical px.
|
||||||
|
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
|
||||||
|
/// floor with 8 px of glass around them.
|
||||||
|
pub(crate) const TAB_BAR_HEIGHT: f32 = 64.0;
|
||||||
|
|
||||||
|
/// Gap between the bar and the bottom screen edge (the "floating" margin;
|
||||||
|
/// the Android gesture-bar inset is added on top by `SafeAreaAnchoredBottom`).
|
||||||
|
const TAB_BAR_MARGIN: f32 = SPACE_3;
|
||||||
|
|
||||||
|
/// Vertical space UI stacked above the bar (the toast stack) must clear:
|
||||||
|
/// floating margin + bar height + one spacing step of breathing room.
|
||||||
|
pub(crate) const TAB_BAR_CLEARANCE_PX: f32 = TAB_BAR_MARGIN + TAB_BAR_HEIGHT + SPACE_2;
|
||||||
|
|
||||||
|
/// Diameter of a button inside the pill (and its fully-round radius ×2).
|
||||||
|
const TAB_BUTTON_SIZE: f32 = 48.0;
|
||||||
|
|
||||||
|
/// Displayed icon size in logical px (texture is rasterised larger; see
|
||||||
|
/// `assets::hud_icon_svg::HUD_ICON_TEXTURE_PX`).
|
||||||
|
const TAB_ICON_PX: f32 = 24.0;
|
||||||
|
|
||||||
|
/// FiraMono glyph advance as a fraction of font size. The bar is
|
||||||
|
/// monospace-only, so `chars × advance × font_size` is the exact label
|
||||||
|
/// width — the same ratio `action_bar_font_size` documents.
|
||||||
|
const FIRA_ADVANCE: f32 = 0.62;
|
||||||
|
|
||||||
|
/// Gap between an icon and its revealed label, folded into the animated
|
||||||
|
/// wrapper width so it collapses together with the text.
|
||||||
|
const LABEL_LEAD_PX: f32 = SPACE_2;
|
||||||
|
|
||||||
|
/// Idle fill of an inactive icon button — fully transparent so the pill's
|
||||||
|
/// glass shows through untouched.
|
||||||
|
const GLASS_BTN_IDLE: Color = Color::srgba(1.0, 1.0, 1.0, 0.0);
|
||||||
|
/// Hover fill of an inactive icon button (desktop pointer / DPAD focus).
|
||||||
|
const GLASS_BTN_HOVER: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||||
|
/// Pressed fill of an inactive icon button.
|
||||||
|
const GLASS_BTN_PRESSED: Color = Color::srgba(1.0, 1.0, 1.0, 0.14);
|
||||||
|
/// Pressed state of the active (accent-filled) pill — darker than idle so
|
||||||
|
/// the press reads as the surface receding, mirroring `BG_ELEVATED_PRESSED`.
|
||||||
|
const GLASS_ACTIVE_PRESSED: Color = Color::srgb(0.55, 0.22, 0.22);
|
||||||
|
|
||||||
|
/// Idle / hovered / pressed fills for one tab-bar button. Presence of this
|
||||||
|
/// component also routes the button to `paint_glass_tab_buttons` instead of
|
||||||
|
/// the legacy `paint_action_buttons` (which excludes it).
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct GlassTabButton {
|
||||||
|
idle: Color,
|
||||||
|
hovered: Color,
|
||||||
|
pressed: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animated label-reveal state, on the clipping wrapper node between the
|
||||||
|
/// button and its `Text` label. `progress` ∈ [0, 1] maps to wrapper width
|
||||||
|
/// via [`expansion_width`]; `target` is where it is heading.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct TabLabelWrap {
|
||||||
|
/// Current reveal amount, 0 = collapsed, 1 = fully expanded.
|
||||||
|
pub progress: f32,
|
||||||
|
/// Reveal amount the animation is moving towards.
|
||||||
|
pub target: f32,
|
||||||
|
/// `true` keeps the label expanded regardless of interaction —
|
||||||
|
/// the persistent Draw pill, and every button in text-fallback mode.
|
||||||
|
persistent: bool,
|
||||||
|
/// Label length; width is recomputed from the live font size each
|
||||||
|
/// frame so viewport-driven font rescales stay correct.
|
||||||
|
label_chars: usize,
|
||||||
|
/// Last width written to the node, to skip no-op layout writes.
|
||||||
|
last_width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on the label `Text` inside a [`TabLabelWrap`].
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct TabLabel;
|
||||||
|
|
||||||
|
/// Duck-away state on the bar container: while a card drag is committed
|
||||||
|
/// the whole bar slides below the screen edge so it never occludes a
|
||||||
|
/// drop target on the bottom tableau rows, then slides back on release.
|
||||||
|
/// `progress` ∈ [0, 1]; 1 = fully off-screen.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct TabBarDuck {
|
||||||
|
/// Current slide amount, animated by [`duck_tab_bar_during_drag`].
|
||||||
|
pub progress: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra slide distance past the bar's own height so its drop shadow
|
||||||
|
/// (16 px blur + 6 px offset) also clears the screen edge.
|
||||||
|
const DUCK_SHADOW_ALLOWANCE_PX: f32 = 24.0;
|
||||||
|
|
||||||
|
/// Rasterised icon textures for the five tab-bar actions.
|
||||||
|
pub(super) struct TabIcons {
|
||||||
|
undo: Handle<Image>,
|
||||||
|
draw: Handle<Image>,
|
||||||
|
hint: Handle<Image>,
|
||||||
|
pause: Handle<Image>,
|
||||||
|
menu: Handle<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TabIcons {
|
||||||
|
/// Rasterise all five icons into `images`. Returns `None` (and warns)
|
||||||
|
/// if any icon fails — the caller then falls back to text-only labels,
|
||||||
|
/// so a bad SVG degrades the look, never the playability.
|
||||||
|
pub(super) fn rasterize_into(images: &mut Assets<Image>) -> Option<Self> {
|
||||||
|
let mut add = |icon: HudIcon| match icon.rasterize() {
|
||||||
|
Ok(img) => Some(images.add(img)),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("tab_bar: failed to rasterise {icon:?} icon: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(Self {
|
||||||
|
undo: add(HudIcon::Undo)?,
|
||||||
|
draw: add(HudIcon::Draw)?,
|
||||||
|
hint: add(HudIcon::Hint)?,
|
||||||
|
pause: add(HudIcon::Pause)?,
|
||||||
|
menu: add(HudIcon::Menu)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper width in logical px for a label of `chars` monospace glyphs at
|
||||||
|
/// `font_size`, revealed to `progress`. Pure so the animation curve is
|
||||||
|
/// unit-testable without an app.
|
||||||
|
pub(super) fn expansion_width(chars: usize, font_size: f32, progress: f32) -> f32 {
|
||||||
|
progress.clamp(0.0, 1.0) * (LABEL_LEAD_PX + chars as f32 * font_size * FIRA_ADVANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance `progress` towards `target` by one frame of `dt` seconds.
|
||||||
|
/// Linear tween over `MOTION_SLIDE_SECS`, snapping when reduce-motion is
|
||||||
|
/// active. Pure for the same reason as [`expansion_width`].
|
||||||
|
pub(super) fn step_expansion(progress: f32, target: f32, dt: f32, reduce_motion: bool) -> f32 {
|
||||||
|
if reduce_motion {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
let max_step = dt / crate::ui_theme::MOTION_SLIDE_SECS;
|
||||||
|
progress + (target - progress).clamp(-max_step, max_step)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the floating glass tab bar: one full-width invisible flex row
|
||||||
|
/// (carrying the `HudActionBar` marker so chrome show/hide keeps working)
|
||||||
|
/// centring the glass pill and the detached circular Menu button.
|
||||||
|
///
|
||||||
|
/// `icons: None` (rasteriser unavailable, e.g. `MinimalPlugins` tests, or
|
||||||
|
/// an SVG regression) switches every button to text-fallback: labels stay
|
||||||
|
/// expanded so the bar remains fully usable without textures.
|
||||||
|
pub(super) fn spawn_glass_tab_bar(
|
||||||
|
commands: &mut Commands,
|
||||||
|
font: &TextFont,
|
||||||
|
icons: Option<&TabIcons>,
|
||||||
|
) {
|
||||||
|
let text_fallback = icons.is_none();
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(TAB_BAR_MARGIN),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredBottom {
|
||||||
|
base_bottom: TAB_BAR_MARGIN,
|
||||||
|
},
|
||||||
|
HudActionBar,
|
||||||
|
TabBarDuck { progress: 0.0 },
|
||||||
|
UiTransform::IDENTITY,
|
||||||
|
))
|
||||||
|
.with_children(|bar| {
|
||||||
|
// The main pill: Undo · [Draw] · Hint · Pause.
|
||||||
|
bar.spawn((
|
||||||
|
Node {
|
||||||
|
height: Val::Px(TAB_BAR_HEIGHT),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
padding: UiRect::axes(Val::Px(SPACE_2), Val::Px(0.0)),
|
||||||
|
column_gap: Val::Px(4.0),
|
||||||
|
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
glass_surface(),
|
||||||
|
))
|
||||||
|
.with_children(|pill| {
|
||||||
|
spawn_tab_button(
|
||||||
|
pill,
|
||||||
|
UndoButton,
|
||||||
|
"Undo",
|
||||||
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||||
|
font,
|
||||||
|
0,
|
||||||
|
icons.map(|i| i.undo.clone()),
|
||||||
|
false,
|
||||||
|
text_fallback,
|
||||||
|
);
|
||||||
|
spawn_tab_button(
|
||||||
|
pill,
|
||||||
|
DrawButton,
|
||||||
|
"Draw",
|
||||||
|
"Draw from the stock — same as tapping the deck.",
|
||||||
|
font,
|
||||||
|
1,
|
||||||
|
icons.map(|i| i.draw.clone()),
|
||||||
|
true,
|
||||||
|
text_fallback,
|
||||||
|
);
|
||||||
|
spawn_tab_button(
|
||||||
|
pill,
|
||||||
|
HintButton,
|
||||||
|
TOUCH_HINT_LABEL,
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
font,
|
||||||
|
2,
|
||||||
|
icons.map(|i| i.hint.clone()),
|
||||||
|
false,
|
||||||
|
text_fallback,
|
||||||
|
);
|
||||||
|
spawn_tab_button(
|
||||||
|
pill,
|
||||||
|
PauseButton,
|
||||||
|
"Pause",
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
font,
|
||||||
|
3,
|
||||||
|
icons.map(|i| i.pause.clone()),
|
||||||
|
false,
|
||||||
|
text_fallback,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The detached circular Menu button, same glass material.
|
||||||
|
bar.spawn((
|
||||||
|
MenuButton,
|
||||||
|
ActionButton,
|
||||||
|
Button,
|
||||||
|
GlassTabButton {
|
||||||
|
idle: GLASS_BTN_IDLE,
|
||||||
|
hovered: GLASS_BTN_HOVER,
|
||||||
|
pressed: GLASS_BTN_PRESSED,
|
||||||
|
},
|
||||||
|
Tooltip::new(
|
||||||
|
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
),
|
||||||
|
Focusable {
|
||||||
|
group: FocusGroup::Hud,
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
width: Val::Px(TAB_BAR_HEIGHT),
|
||||||
|
height: Val::Px(TAB_BAR_HEIGHT),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(GLASS_BTN_IDLE),
|
||||||
|
glass_surface(),
|
||||||
|
))
|
||||||
|
.with_children(|circle| {
|
||||||
|
if let Some(icons) = icons {
|
||||||
|
spawn_tab_icon(circle, icons.menu.clone(), TEXT_PRIMARY);
|
||||||
|
} else {
|
||||||
|
// Text fallback: a menu glyph-free label keeps the
|
||||||
|
// button discoverable without any texture.
|
||||||
|
circle.spawn((
|
||||||
|
Text::new("Menu"),
|
||||||
|
TextLayout::new_with_linebreak(LineBreak::NoWrap),
|
||||||
|
font.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns one button inside the pill: icon, then the width-animated label
|
||||||
|
/// wrapper. `active` gives the accent-filled persistent pill (Draw);
|
||||||
|
/// `text_fallback` pins every label open when icons are unavailable.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_tab_button<M: Component>(
|
||||||
|
pill: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
icon: Option<Handle<Image>>,
|
||||||
|
active: bool,
|
||||||
|
text_fallback: bool,
|
||||||
|
) {
|
||||||
|
let (idle, hovered, pressed) = if active {
|
||||||
|
(ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, GLASS_ACTIVE_PRESSED)
|
||||||
|
} else {
|
||||||
|
(GLASS_BTN_IDLE, GLASS_BTN_HOVER, GLASS_BTN_PRESSED)
|
||||||
|
};
|
||||||
|
let icon_tint = if active { TEXT_PRIMARY } else { TEXT_SECONDARY };
|
||||||
|
let persistent = active || text_fallback;
|
||||||
|
let start = if persistent { 1.0 } else { 0.0 };
|
||||||
|
let chars = label.chars().count();
|
||||||
|
|
||||||
|
pill.spawn((
|
||||||
|
marker,
|
||||||
|
ActionButton,
|
||||||
|
Button,
|
||||||
|
GlassTabButton {
|
||||||
|
idle,
|
||||||
|
hovered,
|
||||||
|
pressed,
|
||||||
|
},
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Focusable {
|
||||||
|
group: FocusGroup::Hud,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
height: Val::Px(TAB_BUTTON_SIZE),
|
||||||
|
min_width: Val::Px(TAB_BUTTON_SIZE),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
padding: UiRect::axes(Val::Px(SPACE_3), Val::Px(0.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(TAB_BUTTON_SIZE / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(idle),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
if let Some(handle) = icon {
|
||||||
|
spawn_tab_icon(b, handle, icon_tint);
|
||||||
|
}
|
||||||
|
// The clipping wrapper the animation drives. Label text sits
|
||||||
|
// left-anchored inside, so growing the width reveals it
|
||||||
|
// left-to-right and shrinking clips it from the right.
|
||||||
|
b.spawn((
|
||||||
|
TabLabelWrap {
|
||||||
|
progress: start,
|
||||||
|
target: start,
|
||||||
|
persistent,
|
||||||
|
label_chars: chars,
|
||||||
|
last_width: f32::NAN,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
overflow: Overflow::clip(),
|
||||||
|
width: Val::Px(expansion_width(chars, font.font_size, start)),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
justify_content: JustifyContent::FlexEnd,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|wrap| {
|
||||||
|
let mut color = TEXT_PRIMARY;
|
||||||
|
color.set_alpha(start);
|
||||||
|
wrap.spawn((
|
||||||
|
TabLabel,
|
||||||
|
ActionButtonLabel,
|
||||||
|
Text::new(label),
|
||||||
|
TextLayout::new_with_linebreak(LineBreak::NoWrap),
|
||||||
|
font.clone(),
|
||||||
|
TextColor(color),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One fixed-size tinted icon node.
|
||||||
|
fn spawn_tab_icon(parent: &mut ChildSpawnerCommands, handle: Handle<Image>, tint: Color) {
|
||||||
|
parent.spawn((
|
||||||
|
ImageNode {
|
||||||
|
color: tint,
|
||||||
|
..ImageNode::new(handle)
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
width: Val::Px(TAB_ICON_PX),
|
||||||
|
height: Val::Px(TAB_ICON_PX),
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets each label wrapper's animation target from its button's
|
||||||
|
/// interaction state: persistent wrappers pin to 1, the rest expand while
|
||||||
|
/// pressed or hovered and collapse on release.
|
||||||
|
pub(super) fn drive_tab_expansion(
|
||||||
|
buttons: Query<(&Interaction, &Children), With<GlassTabButton>>,
|
||||||
|
mut wraps: Query<&mut TabLabelWrap>,
|
||||||
|
) {
|
||||||
|
for (interaction, children) in &buttons {
|
||||||
|
for child in children.iter() {
|
||||||
|
let Ok(mut wrap) = wraps.get_mut(child) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let target = if wrap.persistent || !matches!(interaction, Interaction::None) {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
// Bypass change detection on no-ops so `animate_tab_expansion`
|
||||||
|
// and layout stay idle while nothing moves.
|
||||||
|
if wrap.target != target {
|
||||||
|
wrap.target = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every label reveal towards its target and writes the wrapper
|
||||||
|
/// width + label alpha. Width is derived from the label's *live* font size,
|
||||||
|
/// so the Fold's posture-change font rescale re-widens expanded labels
|
||||||
|
/// automatically. Node writes are skipped when the width is unchanged to
|
||||||
|
/// avoid dirtying UI layout every frame.
|
||||||
|
pub(super) fn animate_tab_expansion(
|
||||||
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut wraps: Query<(&mut TabLabelWrap, &mut Node, &Children)>,
|
||||||
|
mut labels: Query<(&TextFont, &mut TextColor), With<TabLabel>>,
|
||||||
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (mut wrap, mut node, children) in &mut wraps {
|
||||||
|
for child in children.iter() {
|
||||||
|
let Ok((font, mut text_color)) = labels.get_mut(child) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let next = step_expansion(wrap.progress, wrap.target, dt, reduce_motion);
|
||||||
|
let width = expansion_width(wrap.label_chars, font.font_size, next);
|
||||||
|
// `!=` (not epsilon) is deliberate: last_width starts NaN so the
|
||||||
|
// first frame always writes, and equal floats mean a true no-op.
|
||||||
|
if next != wrap.progress || width != wrap.last_width {
|
||||||
|
wrap.progress = next;
|
||||||
|
wrap.last_width = width;
|
||||||
|
node.width = Val::Px(width);
|
||||||
|
text_color.0.set_alpha(next);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slides the bar off the bottom edge while a card drag is committed and
|
||||||
|
/// back once it ends. Effectively touch-only: the desktop docked bar
|
||||||
|
/// never carries [`TabBarDuck`], so the query is empty there.
|
||||||
|
///
|
||||||
|
/// The slide animates `UiTransform::translation` (a render-time offset,
|
||||||
|
/// +y = down) rather than `Node::bottom`, for two reasons: layout is
|
||||||
|
/// never dirtied mid-drag (the whole point is not to cost frames while
|
||||||
|
/// the player is dragging), and `Node::bottom` is owned by the safe-area
|
||||||
|
/// anchor system — two writers on one field would fight.
|
||||||
|
pub(super) fn duck_tab_bar_during_drag(
|
||||||
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
drag: Option<Res<DragState>>,
|
||||||
|
insets: Option<Res<SafeAreaInsets>>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut bars: Query<(&mut TabBarDuck, &mut UiTransform), With<HudActionBar>>,
|
||||||
|
) {
|
||||||
|
let target = if drag.as_deref().is_some_and(|d| d.committed) {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
// Physical-px inset → logical, same conversion the safe-area anchors
|
||||||
|
// apply (CLAUDE.md §10): the bar sits `inset` above its base position,
|
||||||
|
// so the slide must cover that extra travel to fully clear the edge.
|
||||||
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
|
let inset_logical = insets.as_deref().map_or(0.0, |i| i.bottom / scale);
|
||||||
|
let slide_px = TAB_BAR_CLEARANCE_PX + inset_logical + DUCK_SHADOW_ALLOWANCE_PX;
|
||||||
|
for (mut duck, mut transform) in &mut bars {
|
||||||
|
let next = step_expansion(duck.progress, target, dt, reduce_motion);
|
||||||
|
if next == duck.progress && duck.progress == target {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
duck.progress = next;
|
||||||
|
transform.translation = Val2::px(0.0, next * slide_px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle / hover / pressed feedback for glass tab buttons — the glass
|
||||||
|
/// counterpart of `paint_action_buttons`, using each button's own colour
|
||||||
|
/// triple so the accent-filled active pill and the transparent icon
|
||||||
|
/// buttons share one system.
|
||||||
|
pub(super) fn paint_glass_tab_buttons(
|
||||||
|
mut buttons: Query<(&Interaction, &GlassTabButton, &mut BackgroundColor), Changed<Interaction>>,
|
||||||
|
) {
|
||||||
|
for (interaction, style, mut bg) in &mut buttons {
|
||||||
|
bg.0 = match interaction {
|
||||||
|
Interaction::Pressed => style.pressed,
|
||||||
|
Interaction::Hovered => style.hovered,
|
||||||
|
Interaction::None => style.idle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,611 @@
|
|||||||
|
//! Per-frame HUD text/typography/visibility updater systems.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
use solitaire_core::Suit;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
|
use crate::auto_complete_plugin::AutoCompleteState;
|
||||||
|
|
||||||
|
/// Formats a time-limit value in seconds as `"mm:ss"` for HUD display.
|
||||||
|
///
|
||||||
|
/// For example `format_time_limit(300)` returns `"5:00"`.
|
||||||
|
pub fn format_time_limit(secs: u64) -> String {
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
format!("{m}:{s:02}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Score-change feedback (G2)
|
||||||
|
//
|
||||||
|
// The flow for each Update tick:
|
||||||
|
// 1. `detect_score_change` diffs `GameStateResource.score` against
|
||||||
|
// `PreviousScore`. On any positive delta it inserts/refreshes
|
||||||
|
// `ScorePulse` on the score readout; on a delta ≥
|
||||||
|
// `SCORE_FLOATER_THRESHOLD` it also spawns a floating "+N" UI text
|
||||||
|
// anchored just below the score.
|
||||||
|
// 2. `advance_score_pulse` ticks the pulse component, applies the
|
||||||
|
// triangular 1.0 → 1.1 → 1.0 scale curve, and removes the
|
||||||
|
// component on completion.
|
||||||
|
// 3. `advance_score_floater` drifts each floater upward, fades it to
|
||||||
|
// transparent, and despawns it when its lifetime expires.
|
||||||
|
//
|
||||||
|
// The threshold of 50 (a foundation promotion's typical bonus) keeps
|
||||||
|
// floaters rare and meaningful — see `SCORE_FLOATER_THRESHOLD`.
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Sets the [`HudWonPreviously`] text to "✓ Won before" whenever the
|
||||||
|
/// current deal's seed + draw_mode + mode triple matches an entry in
|
||||||
|
/// the rolling [`ReplayHistory`]. Cleared while the active game is won
|
||||||
|
/// (the on-screen "Game won!" cue already conveys victory) and on
|
||||||
|
/// fresh deals the player hasn't won before.
|
||||||
|
///
|
||||||
|
/// Lives in its own system rather than `update_hud` to keep this
|
||||||
|
/// orthogonal: `update_hud`'s query disambiguation is already busy
|
||||||
|
/// enough; threading another marker through every Without filter
|
||||||
|
/// would touch ~10 unrelated queries for no benefit.
|
||||||
|
pub(super) fn update_won_previously(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
// Optional because the HUD plugin's headless tests run without
|
||||||
|
// `StatsPlugin` and therefore without this resource. With the
|
||||||
|
// resource absent there's no history to compare against; the
|
||||||
|
// indicator just stays empty.
|
||||||
|
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
|
||||||
|
mut q: Query<&mut Text, With<HudWonPreviously>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut text) = q.single_mut() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let won_before = !game.0.is_won()
|
||||||
|
&& history.as_ref().is_some_and(|h| {
|
||||||
|
h.0.replays.iter().any(|r| {
|
||||||
|
r.seed == game.0.seed && r.draw_mode == game.0.draw_mode() && r.mode == game.0.mode
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let next = if won_before {
|
||||||
|
"\u{2713} Won before"
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
if text.0 != next {
|
||||||
|
text.0 = next.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::type_complexity, clippy::too_many_arguments)]
|
||||||
|
pub(super) fn update_hud(
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
time_attack: Option<Res<TimeAttackResource>>,
|
||||||
|
daily: Option<Res<DailyChallengeResource>>,
|
||||||
|
auto_complete: Option<Res<AutoCompleteState>>,
|
||||||
|
mut score_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut moves_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudMoves>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut time_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudTime>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut mode_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudMode>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut challenge_q: Query<
|
||||||
|
(&mut Text, &mut TextColor),
|
||||||
|
(
|
||||||
|
With<HudChallenge>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut undos_q: Query<
|
||||||
|
(&mut Text, &mut TextColor),
|
||||||
|
(
|
||||||
|
With<HudUndos>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut auto_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudAutoComplete>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut recycles_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudRecycles>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudDrawCycle>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut draw_cycle_q: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HudDrawCycle>,
|
||||||
|
Without<HudScore>,
|
||||||
|
Without<HudMoves>,
|
||||||
|
Without<HudTime>,
|
||||||
|
Without<HudMode>,
|
||||||
|
Without<HudChallenge>,
|
||||||
|
Without<HudUndos>,
|
||||||
|
Without<HudAutoComplete>,
|
||||||
|
Without<HudRecycles>,
|
||||||
|
Without<HudSelection>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let ta_active = time_attack.as_ref().is_some_and(|ta| ta.active);
|
||||||
|
|
||||||
|
// Score, moves, mode, challenge, and undos only need updating when game state changes.
|
||||||
|
if game.is_changed() {
|
||||||
|
let g = &game.0;
|
||||||
|
let is_zen = g.mode == GameMode::Zen;
|
||||||
|
if let Ok(mut t) = score_q.single_mut() {
|
||||||
|
// Zen mode suppresses score display per spec ("No score display").
|
||||||
|
**t = if is_zen {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
format!("Score: {}", g.score())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if let Ok(mut t) = moves_q.single_mut() {
|
||||||
|
**t = format!("Moves: {}", g.move_count());
|
||||||
|
}
|
||||||
|
if let Ok(mut t) = mode_q.single_mut() {
|
||||||
|
**t = match g.mode {
|
||||||
|
GameMode::Classic => match g.draw_mode() {
|
||||||
|
DrawStockConfig::DrawOne => String::new(),
|
||||||
|
DrawStockConfig::DrawThree => "Draw 3".to_string(),
|
||||||
|
},
|
||||||
|
GameMode::Zen => "ZEN".to_string(),
|
||||||
|
GameMode::Challenge => "CHALLENGE".to_string(),
|
||||||
|
GameMode::TimeAttack => "TIME ATTACK".to_string(),
|
||||||
|
GameMode::Difficulty(level) => level.label().to_uppercase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Daily challenge constraint (with time-low colour warning) ---
|
||||||
|
if let Ok((mut t, mut color)) = challenge_q.single_mut() {
|
||||||
|
if g.is_won() {
|
||||||
|
**t = String::new();
|
||||||
|
} else if let Some(dc) = daily.as_deref() {
|
||||||
|
**t = challenge_hud_text(dc);
|
||||||
|
if let Some(max_secs) = dc.max_time_secs {
|
||||||
|
let remaining = max_secs.saturating_sub(g.elapsed_seconds);
|
||||||
|
*color = TextColor(challenge_time_color(remaining));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
**t = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Undo count ---
|
||||||
|
if let Ok((mut t, mut color)) = undos_q.single_mut() {
|
||||||
|
let count = g.undo_count();
|
||||||
|
if count == 0 {
|
||||||
|
**t = String::new();
|
||||||
|
*color = TextColor(TEXT_PRIMARY);
|
||||||
|
} else {
|
||||||
|
**t = format!("Undos: {count}");
|
||||||
|
// STATE_WARNING signals "you took a penalty" — same hue
|
||||||
|
// as the Recycles counter so they read as one category.
|
||||||
|
*color = TextColor(STATE_WARNING);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Recycle counter (both modes, hidden until first recycle) ---
|
||||||
|
if let Ok(mut t) = recycles_q.single_mut() {
|
||||||
|
**t = if g.recycle_count() > 0 {
|
||||||
|
format!("Recycles: {}", g.recycle_count())
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Draw-cycle indicator (Draw-Three mode only) ---
|
||||||
|
if let Ok(mut t) = draw_cycle_q.single_mut() {
|
||||||
|
**t = if g.is_won() || g.draw_mode() != DrawStockConfig::DrawThree {
|
||||||
|
// Hide when not in Draw-Three or after the game is won.
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
let stock_len = g.stock_cards().len();
|
||||||
|
let next_draw = stock_len.min(3);
|
||||||
|
format!("Cycle: {next_draw}/3")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Time display: show Time Attack countdown every frame when active;
|
||||||
|
// Zen mode suppresses the timer per spec ("No timer") — cleared unconditionally
|
||||||
|
// every frame so it disappears immediately on the frame Z is pressed.
|
||||||
|
// Otherwise show game elapsed time (updates once per second via game.is_changed()).
|
||||||
|
let is_zen = game.0.mode == GameMode::Zen;
|
||||||
|
let update_time = (ta_active || game.is_changed()) && !is_zen;
|
||||||
|
if update_time {
|
||||||
|
if let Ok(mut t) = time_q.single_mut() {
|
||||||
|
if let Some(ta) = time_attack.as_ref().filter(|ta| ta.active) {
|
||||||
|
let remaining = ta.remaining_secs.max(0.0) as u64;
|
||||||
|
let m = remaining / 60;
|
||||||
|
let s = remaining % 60;
|
||||||
|
**t = format!("{m}:{s:02}");
|
||||||
|
} else {
|
||||||
|
let secs = game.0.elapsed_seconds;
|
||||||
|
let m = secs / 60;
|
||||||
|
let s = secs % 60;
|
||||||
|
**t = format!("{m}:{s:02}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if is_zen {
|
||||||
|
// Clear the time display immediately whenever Zen mode is active —
|
||||||
|
// do not guard on game.is_changed() so it clears on the same frame
|
||||||
|
// the player presses Z, before any move is made.
|
||||||
|
if let Ok(mut t) = time_q.single_mut() {
|
||||||
|
**t = String::new();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Auto-complete badge ---
|
||||||
|
// Reflects the AutoCompleteState resource; update whenever it changes or game changes.
|
||||||
|
let ac_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||||
|
let ac_changed = auto_complete.as_ref().is_some_and(|ac| ac.is_changed());
|
||||||
|
if (ac_changed || game.is_changed())
|
||||||
|
&& let Ok(mut t) = auto_q.single_mut()
|
||||||
|
{
|
||||||
|
**t = if ac_active {
|
||||||
|
"AUTO".to_string()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates the `HudSelection` text node to show which pile is Tab-selected.
|
||||||
|
///
|
||||||
|
/// Displays `"▶ {pile_name}"` while `SelectionState::selected_pile` is `Some`,
|
||||||
|
/// or an empty string when no pile is selected. Runs every frame so the
|
||||||
|
/// indicator stays in sync with the selection resource.
|
||||||
|
pub(super) fn update_selection_hud(
|
||||||
|
selection: Option<Res<SelectionState>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut q: Query<&mut Text, With<HudSelection>>,
|
||||||
|
) {
|
||||||
|
let Ok(mut t) = q.single_mut() else { return };
|
||||||
|
let label = match selection.as_deref().and_then(|s| s.selected_pile.as_ref()) {
|
||||||
|
None => String::new(),
|
||||||
|
Some(KlondikePile::Stock) => "▶ Waste".to_string(),
|
||||||
|
Some(KlondikePile::Foundation(slot)) => match game.as_deref() {
|
||||||
|
Some(g) => foundation_selection_label(*slot, &g.0),
|
||||||
|
// No game resource means we can't probe claimed_suit; show the
|
||||||
|
// slot-based placeholder so the HUD still surfaces the selection.
|
||||||
|
None => format!("▶ Foundation {}", foundation_number(*slot)),
|
||||||
|
},
|
||||||
|
Some(KlondikePile::Tableau(idx)) => format!("▶ Column {}", tableau_number(*idx)),
|
||||||
|
};
|
||||||
|
**t = label;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the HUD selection label for a foundation slot.
|
||||||
|
///
|
||||||
|
/// When the slot has a claimed suit (any card has landed) the announcement is
|
||||||
|
/// "▶ {Suit} Foundation"; while the slot is empty it falls back to a
|
||||||
|
/// "▶ Foundation N" placeholder labelled by the 1-based slot index.
|
||||||
|
pub(super) fn foundation_selection_label(
|
||||||
|
slot: Foundation,
|
||||||
|
game: &solitaire_core::game_state::GameState,
|
||||||
|
) -> String {
|
||||||
|
let claimed = game
|
||||||
|
.pile(KlondikePile::Foundation(slot))
|
||||||
|
.first()
|
||||||
|
.map(|c| c.0.suit());
|
||||||
|
match claimed {
|
||||||
|
Some(suit) => {
|
||||||
|
let s = match suit {
|
||||||
|
Suit::Clubs => "Clubs",
|
||||||
|
Suit::Diamonds => "Diamonds",
|
||||||
|
Suit::Hearts => "Hearts",
|
||||||
|
Suit::Spades => "Spades",
|
||||||
|
};
|
||||||
|
format!("▶ {s} Foundation")
|
||||||
|
}
|
||||||
|
None => format!("▶ Foundation {}", foundation_number(slot)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn foundation_number(foundation: Foundation) -> u8 {
|
||||||
|
match foundation {
|
||||||
|
Foundation::Foundation1 => 1,
|
||||||
|
Foundation::Foundation2 => 2,
|
||||||
|
Foundation::Foundation3 => 3,
|
||||||
|
Foundation::Foundation4 => 4,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fn tableau_number(tableau: Tableau) -> u8 {
|
||||||
|
match tableau {
|
||||||
|
Tableau::Tableau1 => 1,
|
||||||
|
Tableau::Tableau2 => 2,
|
||||||
|
Tableau::Tableau3 => 3,
|
||||||
|
Tableau::Tableau4 => 4,
|
||||||
|
Tableau::Tableau5 => 5,
|
||||||
|
Tableau::Tableau6 => 6,
|
||||||
|
Tableau::Tableau7 => 7,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires `InfoToastEvent("Auto-completing...")` exactly once each time
|
||||||
|
/// `AutoCompleteState` transitions from inactive to active. Uses a `Local<bool>`
|
||||||
|
/// to debounce so the toast only appears on the leading edge.
|
||||||
|
pub(super) fn announce_auto_complete(
|
||||||
|
auto_complete: Option<Res<AutoCompleteState>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut was_active: Local<bool>,
|
||||||
|
) {
|
||||||
|
let now_active = auto_complete.as_ref().is_some_and(|ac| ac.active);
|
||||||
|
if now_active && !*was_active {
|
||||||
|
toast.write(InfoToastEvent("Auto-completing...".to_string()));
|
||||||
|
}
|
||||||
|
*was_active = now_active;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds the HUD text for the active daily challenge constraints.
|
||||||
|
///
|
||||||
|
/// Returns `"Limit: mm:ss"` when a time limit is set, `"Goal: N pts"` when a
|
||||||
|
/// score target is set, or an empty string when the challenge has no extra
|
||||||
|
/// constraints.
|
||||||
|
pub(super) fn challenge_hud_text(dc: &DailyChallengeResource) -> String {
|
||||||
|
if let Some(secs) = dc.max_time_secs {
|
||||||
|
format!("Limit: {}", format_time_limit(secs))
|
||||||
|
} else if let Some(score) = dc.target_score {
|
||||||
|
format!("Goal: {score} pts")
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the colour for the challenge time-limit HUD label based on
|
||||||
|
/// seconds remaining. Uses theme tokens so the urgency ramp picks up
|
||||||
|
/// palette changes for free.
|
||||||
|
///
|
||||||
|
/// | Remaining | Token |
|
||||||
|
/// |-------------|------------------|
|
||||||
|
/// | ≥ 60 s | `STATE_INFO` |
|
||||||
|
/// | 30 – 59 s | `STATE_WARNING` |
|
||||||
|
/// | < 30 s | `STATE_DANGER` |
|
||||||
|
pub fn challenge_time_color(remaining: u64) -> Color {
|
||||||
|
if remaining < 30 {
|
||||||
|
STATE_DANGER
|
||||||
|
} else if remaining < 60 {
|
||||||
|
STATE_WARNING
|
||||||
|
} else {
|
||||||
|
STATE_INFO
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scales HUD Tier-1 font sizes to fit a narrow viewport.
|
||||||
|
///
|
||||||
|
/// Fires on every `WindowResized` event. Below 480 logical pixels wide the
|
||||||
|
/// score drops from `TYPE_HEADLINE` (26 px) to `TYPE_BODY_LG` (18 px) and the
|
||||||
|
/// Moves/Timer labels drop from `TYPE_BODY_LG` to `TYPE_CAPTION` (11 px), so
|
||||||
|
/// all three items remain on one row inside the 50 %-wide HUD column
|
||||||
|
/// (≈ 180 dp on a 360 dp phone). At ≥ 480 px the original sizes are
|
||||||
|
/// restored so desktop/tablet layouts are unaffected.
|
||||||
|
type HudScoreFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudScore>, Without<HudMoves>, Without<HudTime>)>;
|
||||||
|
type HudMovesFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudMoves>, Without<HudScore>, Without<HudTime>)>;
|
||||||
|
type HudTimeFont<'w, 's> =
|
||||||
|
Query<'w, 's, &'static mut TextFont, (With<HudTime>, Without<HudScore>, Without<HudMoves>)>;
|
||||||
|
|
||||||
|
pub(super) fn update_hud_typography(
|
||||||
|
mut events: MessageReader<WindowResized>,
|
||||||
|
mut score_q: HudScoreFont,
|
||||||
|
mut moves_q: HudMovesFont,
|
||||||
|
mut time_q: HudTimeFont,
|
||||||
|
) {
|
||||||
|
let Some(ev) = events.read().last() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let (score_size, secondary_size) = if ev.width < 480.0 {
|
||||||
|
(TYPE_BODY_LG, TYPE_CAPTION)
|
||||||
|
} else {
|
||||||
|
(TYPE_HEADLINE, TYPE_BODY_LG)
|
||||||
|
};
|
||||||
|
for mut font in &mut score_q {
|
||||||
|
font.font_size = score_size;
|
||||||
|
}
|
||||||
|
for mut font in &mut moves_q {
|
||||||
|
font.font_size = secondary_size;
|
||||||
|
}
|
||||||
|
for mut font in &mut time_q {
|
||||||
|
font.font_size = secondary_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn apply_hud_visibility(
|
||||||
|
hud_vis: Res<HudVisibility>,
|
||||||
|
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
|
||||||
|
) {
|
||||||
|
if !hud_vis.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let v = if *hud_vis == HudVisibility::Visible {
|
||||||
|
Visibility::Visible
|
||||||
|
} else {
|
||||||
|
Visibility::Hidden
|
||||||
|
};
|
||||||
|
for mut vis in &mut action_bar {
|
||||||
|
*vis = v;
|
||||||
|
}
|
||||||
|
// The bottom action bar is a pure overlay — it does not claim any
|
||||||
|
// space in the card layout, so no WindowResized event is needed.
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn restore_hud_on_modal(
|
||||||
|
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||||
|
mut hud_vis: ResMut<HudVisibility>,
|
||||||
|
) {
|
||||||
|
if !new_scrims.is_empty() {
|
||||||
|
*hud_vis = HudVisibility::Visible;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the action-bar label font size for a given logical window width.
|
||||||
|
pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
// Seven word-labels ("Menu","Undo","Pause","Help","Hint","Mode","New")
|
||||||
|
// must share one row. The widest characters are in FiraMono (a
|
||||||
|
// monospace whose advance is ~0.62 of the font size). On a 900
|
||||||
|
// logical-px phone the row budget after bar padding (2*12) and six
|
||||||
|
// 4 px column gaps is ~852 px for ~28 label chars + 7*2*3 px button
|
||||||
|
// padding. Solving 28*0.62*size + 42 <= 852 gives size <= ~46, so the
|
||||||
|
// labels are advance-bound only on very narrow viewports; the real
|
||||||
|
// constraint is legibility, not fit. ~1/60 of the width yields ~15 px
|
||||||
|
// at 900 px — comfortably one row with margin to spare — clamped so it
|
||||||
|
// never drops below the 12 px legibility floor or grows past 18 px on
|
||||||
|
// landscape tablets where it would crowd the row again.
|
||||||
|
(window_width / 60.0).clamp(12.0, 18.0)
|
||||||
|
} else {
|
||||||
|
TYPE_BODY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
// Tight 3 px horizontal padding keeps the compact buttons narrow;
|
||||||
|
// Phase F trimmed the touch bar to 5 buttons (2 compact + 3
|
||||||
|
// primary), so width pressure is lower than the old 7-button row.
|
||||||
|
// min_height stays at 44 px to preserve the comfortable touch
|
||||||
|
// target on the compact buttons.
|
||||||
|
(
|
||||||
|
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||||
|
Val::Px(44.0),
|
||||||
|
Val::Px(44.0),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
|
||||||
|
Val::Px(48.0),
|
||||||
|
Val::Px(48.0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn spawn_action_button_label(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
label: &str,
|
||||||
|
font: &TextFont,
|
||||||
|
text_color: Color,
|
||||||
|
) {
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
parent.spawn((
|
||||||
|
ActionButtonLabel,
|
||||||
|
Text::new(label),
|
||||||
|
font.clone(),
|
||||||
|
TextColor(text_color),
|
||||||
|
));
|
||||||
|
} else {
|
||||||
|
parent.spawn((Text::new(label), font.clone(), TextColor(text_color)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resizes the glyph text inside every [`ActionButtonLabel`] to match the
|
||||||
|
/// current viewport width whenever [`LayoutResource`] changes (orientation
|
||||||
|
/// change or window resize).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
pub(super) fn resize_action_bar_labels(
|
||||||
|
layout: Res<crate::layout::LayoutResource>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut labels: Query<&mut TextFont, With<ActionButtonLabel>>,
|
||||||
|
) {
|
||||||
|
let w = windows
|
||||||
|
.iter()
|
||||||
|
.next()
|
||||||
|
.map_or(layout.0.card_size.x * 7.25, |win| win.width());
|
||||||
|
let new_size = action_bar_font_size(w);
|
||||||
|
for mut font in &mut labels {
|
||||||
|
font.font_size = new_size;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,939 @@
|
|||||||
|
use super::*;
|
||||||
|
use crate::layout::compute_layout;
|
||||||
|
use solitaire_core::{Deck, Foundation, Rank, Suit, Tableau};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
|
||||||
|
fn clear_test_piles(game: &mut GameState) {
|
||||||
|
game.set_test_stock_cards(Vec::new());
|
||||||
|
game.set_test_waste_cards(Vec::new());
|
||||||
|
for foundation in [
|
||||||
|
Foundation::Foundation1,
|
||||||
|
Foundation::Foundation2,
|
||||||
|
Foundation::Foundation3,
|
||||||
|
Foundation::Foundation4,
|
||||||
|
] {
|
||||||
|
game.set_test_foundation_cards(foundation, Vec::new());
|
||||||
|
}
|
||||||
|
for tableau in [
|
||||||
|
Tableau::Tableau1,
|
||||||
|
Tableau::Tableau2,
|
||||||
|
Tableau::Tableau3,
|
||||||
|
Tableau::Tableau4,
|
||||||
|
Tableau::Tableau5,
|
||||||
|
Tableau::Tableau6,
|
||||||
|
Tableau::Tableau7,
|
||||||
|
] {
|
||||||
|
game.set_test_tableau_cards(tableau, Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn dragged_card_z_matches_resting_stack_step() {
|
||||||
|
assert!((dragged_card_z(0) - DRAG_Z).abs() < 1e-6);
|
||||||
|
let step = dragged_card_z(1) - dragged_card_z(0);
|
||||||
|
assert!(
|
||||||
|
step > 0.02,
|
||||||
|
"drag step must exceed Android overlay local_z, got {step}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
step + 1e-4 >= STACK_FAN_FRAC,
|
||||||
|
"drag step must stay aligned with resting stack spacing, got {step}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_inside_returns_true() {
|
||||||
|
let center = Vec2::new(10.0, 20.0);
|
||||||
|
let size = Vec2::new(40.0, 60.0);
|
||||||
|
assert!(point_in_rect(Vec2::new(10.0, 20.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(29.0, 49.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(-9.0, -9.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_on_edge_returns_true() {
|
||||||
|
let center = Vec2::ZERO;
|
||||||
|
let size = Vec2::new(10.0, 10.0);
|
||||||
|
assert!(point_in_rect(Vec2::new(5.0, 5.0), center, size));
|
||||||
|
assert!(point_in_rect(Vec2::new(-5.0, -5.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_in_rect_outside_returns_false() {
|
||||||
|
let center = Vec2::ZERO;
|
||||||
|
let size = Vec2::new(10.0, 10.0);
|
||||||
|
assert!(!point_in_rect(Vec2::new(6.0, 0.0), center, size));
|
||||||
|
assert!(!point_in_rect(Vec2::new(0.0, 6.0), center, size));
|
||||||
|
assert!(!point_in_rect(Vec2::new(-100.0, 0.0), center, size));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_top_of_tableau() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// In tableau 6, the visually topmost card is the last (face-up) one.
|
||||||
|
// Its position: base.y + fan * 6.
|
||||||
|
let top_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
|
let result = find_draggable_at(top_pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
|
assert_eq!(result.1, 6);
|
||||||
|
assert_eq!(result.2.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_waste_top_with_multiple_cards() {
|
||||||
|
// Reproduces the reported "drags the wrong waste card" bug: with several
|
||||||
|
// cards in the waste, clicking the visible top must pick the actual top
|
||||||
|
// (last index), not the buffer card underneath it.
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let waste = vec![
|
||||||
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||||
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||||
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||||
|
];
|
||||||
|
game.set_test_waste_cards(waste.clone());
|
||||||
|
|
||||||
|
let top_index = waste.len() - 1; // 2 = the visible top
|
||||||
|
let top_pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||||
|
let result = find_draggable_at(top_pos, &game, &layout).expect("waste top is draggable");
|
||||||
|
assert_eq!(result.0, KlondikePile::Stock, "origin is the waste pile");
|
||||||
|
assert_eq!(result.1, top_index, "picks the top index, not the buffer");
|
||||||
|
assert_eq!(
|
||||||
|
result.2,
|
||||||
|
vec![waste[top_index].clone()],
|
||||||
|
"drags the top card only"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_picks_lone_waste_card() {
|
||||||
|
// "can't play the first card in the stock" — a waste of one card must
|
||||||
|
// still be draggable.
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let card = Card::new(Deck::Deck1, Suit::Diamonds, Rank::Ace);
|
||||||
|
game.set_test_waste_cards(vec![card.clone()]);
|
||||||
|
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||||
|
let result = find_draggable_at(pos, &game, &layout).expect("lone waste card is draggable");
|
||||||
|
assert_eq!(result.0, KlondikePile::Stock);
|
||||||
|
assert_eq!(result.1, 0);
|
||||||
|
assert_eq!(result.2, vec![card]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_three_waste_hit_test_matches_render_fan_step() {
|
||||||
|
// Regression: the Draw-Three waste hit-test must use the same fan step as
|
||||||
|
// the renderer (`card_plugin::waste_fan_step`). The previous hard-coded
|
||||||
|
// `card_size.x * 0.28` matched the renderer only on desktop (column step =
|
||||||
|
// 1.25*cw); under tighter Android-style spacing the two drift and the top
|
||||||
|
// fanned card's click target lands on the card beneath it — so dragging
|
||||||
|
// the visible top card plays the wrong one.
|
||||||
|
let mut game = GameState::new(7, DrawStockConfig::DrawThree);
|
||||||
|
let mut layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Force tight (Android-like) column spacing: ~1.03 * card_width.
|
||||||
|
let cw = layout.card_size.x;
|
||||||
|
let base = layout.pile_positions[&KlondikePile::Stock];
|
||||||
|
let t1 = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
layout.pile_positions.insert(
|
||||||
|
KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
Vec2::new(t1.x + cw * 1.03, t1.y),
|
||||||
|
);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let waste = vec![
|
||||||
|
Card::new(Deck::Deck1, Suit::Clubs, Rank::Two),
|
||||||
|
Card::new(Deck::Deck1, Suit::Hearts, Rank::Five),
|
||||||
|
Card::new(Deck::Deck1, Suit::Spades, Rank::Nine),
|
||||||
|
Card::new(Deck::Deck1, Suit::Diamonds, Rank::King),
|
||||||
|
];
|
||||||
|
game.set_test_waste_cards(waste.clone());
|
||||||
|
|
||||||
|
// visible_start = len-3 = 1, so the top card sits at fan slot 2.
|
||||||
|
let top_index = waste.len() - 1;
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, top_index);
|
||||||
|
|
||||||
|
let expected = base.x + 2.0 * waste_fan_step(&layout);
|
||||||
|
assert!(
|
||||||
|
(pos.x - expected).abs() < 1e-3,
|
||||||
|
"hit-test must use the shared waste fan step"
|
||||||
|
);
|
||||||
|
// The old fixed constant would have drifted from the renderer here.
|
||||||
|
let old = base.x + 2.0 * cw * 0.28;
|
||||||
|
assert!(
|
||||||
|
(pos.x - old).abs() > 1.0,
|
||||||
|
"shared step must differ from the old fixed step under tight spacing"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_skips_face_down_cards() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Tableau 6 has 7 cards: 6 face-down (indices 0..5) + 1 face-up at
|
||||||
|
// the bottom (index 6). Click at the topmost face-down card's
|
||||||
|
// position — its full body is partly visible above the fanned
|
||||||
|
// face-up card, but the iterator should skip face-down cards and
|
||||||
|
// the cursor sits above the face-up card's AABB, so the result
|
||||||
|
// is None.
|
||||||
|
let face_down_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 0);
|
||||||
|
let result = find_draggable_at(face_down_pos, &game, &layout);
|
||||||
|
assert!(result.is_none(), "face-down cards should not be draggable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_hits_face_up_card_with_face_down_cards_above_it() {
|
||||||
|
// Regression test for the bug where input_plugin's hit-testing used
|
||||||
|
// a uniform 0.25 fan step but card_plugin renders face-down cards
|
||||||
|
// at 0.12 — so for any column with face-down cards above the
|
||||||
|
// face-up bottom card, clicking the visible card face missed the
|
||||||
|
// hit-test box and only the bottom strip of the card responded.
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
|
||||||
|
// Tableau 6 starts with 6 face-down + 1 face-up. The face-up card
|
||||||
|
// sits at base.y - 6 * TABLEAU_FACEDOWN_FAN_FRAC * card_h, NOT at
|
||||||
|
// base.y - 6 * TABLEAU_FAN_FRAC * card_h. Click the centre.
|
||||||
|
let face_up_pos = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau7), 6);
|
||||||
|
let result = find_draggable_at(face_up_pos, &game, &layout)
|
||||||
|
.expect("clicking the face-up card's visible centre must initiate a drag");
|
||||||
|
assert_eq!(result.0, KlondikePile::Tableau(Tableau::Tableau7));
|
||||||
|
assert_eq!(result.1, 6);
|
||||||
|
assert_eq!(result.2.len(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_returns_run_when_picking_mid_stack() {
|
||||||
|
// Manually construct a tableau with three face-up cards all stacked.
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let king = Card::new(D::Deck1, Suit::Spades, Rank::King);
|
||||||
|
let queen = Card::new(D::Deck1, Suit::Hearts, Rank::Queen);
|
||||||
|
let jack = Card::new(D::Deck1, Suit::Clubs, Rank::Jack);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![king, queen.clone(), jack.clone()]);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// The Queen's geometric center (index 1) is inside the Jack's bounding box
|
||||||
|
// (Jack fans 0.5h below base; its box spans [base-h, base]). To hit the
|
||||||
|
// Queen we click in her visible strip: the 0.25h band above the Jack's top
|
||||||
|
// edge (base.y to base.y+0.25h). Midpoint = queen_center + 0.375*card_h.
|
||||||
|
let queen_center = card_position(&game, &layout, &KlondikePile::Tableau(Tableau::Tableau1), 1);
|
||||||
|
let pos = queen_center + Vec2::new(0.0, layout.card_size.y * 0.375);
|
||||||
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(pile, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
assert_eq!(start, 1);
|
||||||
|
assert_eq!(ids, vec![queen, jack]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_skips_non_top_waste_card() {
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||||
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||||
|
game.set_test_waste_cards(vec![two_spades, three_hearts.clone()]);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Both cards in waste sit at the same (x, y). Clicking should pick
|
||||||
|
// the visually top card (three_hearts), with count = 1.
|
||||||
|
let pos = card_position(&game, &layout, &KlondikePile::Stock, 0);
|
||||||
|
let (pile, start, ids) = find_draggable_at(pos, &game, &layout).expect("hit");
|
||||||
|
assert_eq!(pile, KlondikePile::Stock);
|
||||||
|
assert_eq!(start, 1);
|
||||||
|
assert_eq!(ids, vec![three_hearts]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_drop_target_hits_empty_tableau_pile_marker() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Move all cards out of tableau 0 so its marker is the only drop area.
|
||||||
|
let mut game = game;
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
let target = find_drop_target(
|
||||||
|
pos,
|
||||||
|
&game,
|
||||||
|
&layout,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau7),
|
||||||
|
);
|
||||||
|
assert_eq!(target, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_drop_target_returns_none_for_origin() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau4)];
|
||||||
|
let target = find_drop_target(
|
||||||
|
pos,
|
||||||
|
&game,
|
||||||
|
&layout,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau4),
|
||||||
|
);
|
||||||
|
assert_eq!(target, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_drop_rect_extends_for_tableau_with_cards() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Tableau 6 has 7 cards.
|
||||||
|
let (_, size) = pile_drop_rect(&KlondikePile::Tableau(Tableau::Tableau7), &layout, &game);
|
||||||
|
// Expected: card_height + 6 fan steps.
|
||||||
|
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||||
|
assert!(
|
||||||
|
(size.y - expected).abs() < 1e-3,
|
||||||
|
"expected {expected}, got {}",
|
||||||
|
size.y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_draw_three_waste_top_card_hit_at_fanned_position() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
|
let mut game = GameState::new_with_mode(1, DrawStockConfig::DrawThree, GameMode::Classic);
|
||||||
|
// Three waste cards; top (four_clubs) is rightmost in the fan.
|
||||||
|
let two_spades = Card::new(D::Deck1, Suit::Spades, Rank::Two);
|
||||||
|
let three_hearts = Card::new(D::Deck1, Suit::Hearts, Rank::Three);
|
||||||
|
let four_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Four);
|
||||||
|
game.set_test_waste_cards(vec![two_spades, three_hearts, four_clubs.clone()]);
|
||||||
|
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
let waste_base = layout.pile_positions[&KlondikePile::Stock];
|
||||||
|
// Top card (slot=2) is at base.x + 2 * 0.28 * card_width.
|
||||||
|
let top_card_x = waste_base.x + 2.0 * 0.28 * layout.card_size.x;
|
||||||
|
let cursor = Vec2::new(top_card_x, waste_base.y);
|
||||||
|
|
||||||
|
let result = find_draggable_at(cursor, &game, &layout);
|
||||||
|
assert!(
|
||||||
|
result.is_some(),
|
||||||
|
"top fanned waste card must be hittable at its visual X position"
|
||||||
|
);
|
||||||
|
let (pile, _start, ids) = result.unwrap();
|
||||||
|
assert_eq!(pile, KlondikePile::Stock);
|
||||||
|
assert_eq!(
|
||||||
|
ids,
|
||||||
|
vec![four_clubs],
|
||||||
|
"only the top card is draggable from waste"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_draggable_returns_none_for_click_on_empty_pile() {
|
||||||
|
let mut game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
// Clear tableau 0 so it's an empty slot.
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, Vec::new());
|
||||||
|
let pos = layout.pile_positions[&KlondikePile::Tableau(Tableau::Tableau1)];
|
||||||
|
let result = find_draggable_at(pos, &game, &layout);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"clicking an empty pile must not produce a draggable"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pile_drop_rect_is_card_sized_for_non_tableau() {
|
||||||
|
let game = GameState::new(42, DrawStockConfig::DrawOne);
|
||||||
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
|
for pile in [
|
||||||
|
KlondikePile::Stock,
|
||||||
|
KlondikePile::Foundation(Foundation::Foundation3),
|
||||||
|
] {
|
||||||
|
let (_, size) = pile_drop_rect(&pile, &layout, &game);
|
||||||
|
assert_eq!(size, layout.card_size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Task #27 — best_destination pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_destination_returns_none_when_no_legal_move() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Clear everything except one card that has nowhere to go.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// A Two of Clubs with empty foundations and empty tableau has no destination.
|
||||||
|
let card = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||||
|
assert!(best_destination(&card, &game).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// best_tableau_destination_for_stack pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_tableau_destination_for_stack_skips_source_pile() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// Only tableau 0 has anything; every other column is empty.
|
||||||
|
// A King is the only card that can go on an empty tableau column.
|
||||||
|
// Source is Tableau(0), so the result must NOT be Tableau(0).
|
||||||
|
let king = Card::new(D::Deck1, Suit::Hearts, Rank::King);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![king.clone()]);
|
||||||
|
|
||||||
|
let result = best_tableau_destination_for_stack(
|
||||||
|
&king,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
// Result must be some other empty tableau column, never the source.
|
||||||
|
if let Some((dest, _)) = result {
|
||||||
|
assert_ne!(dest, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn best_tableau_destination_for_stack_returns_none_when_no_legal_move() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
|
||||||
|
// Source: tableau 0 has a Two of Clubs (can't go on empty pile; not a King).
|
||||||
|
// All other piles are empty — no legal tableau target.
|
||||||
|
let two_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Two);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![two_clubs.clone()]);
|
||||||
|
|
||||||
|
let result = best_tableau_destination_for_stack(
|
||||||
|
&two_clubs,
|
||||||
|
&KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
&game,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
result.is_none(),
|
||||||
|
"Two of Clubs has no legal tableau destination on empty piles"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// auto_move_for_run pure-function tests (issue #158)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// These need real positions — `can_move_cards` validates against the live
|
||||||
|
// session, not the `set_test_*` overlays. Seeds 51 and 145 both deal an
|
||||||
|
// Ace and its Two on tableau tops plus an opposite-color Three elsewhere,
|
||||||
|
// letting two moves build a face-up [Three, Two] run whose top card is
|
||||||
|
// foundation-eligible (the bait the pre-#158 code would take).
|
||||||
|
|
||||||
|
/// Deal `seed`, send the Ace on tableau 1 to its foundation, then stack the
|
||||||
|
/// matching Two from `two_from` onto the opposite-color Three on `run_on`.
|
||||||
|
/// Returns the game with a 2-card face-up run on `run_on`.
|
||||||
|
fn deal_run_with_foundation_bait(seed: u64, two_from: Tableau, run_on: Tableau) -> GameState {
|
||||||
|
let mut game = GameState::new(seed, DrawStockConfig::DrawOne);
|
||||||
|
let (ace, _) = game
|
||||||
|
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
||||||
|
.last()
|
||||||
|
.cloned()
|
||||||
|
.expect("seed deals a card on tableau 1");
|
||||||
|
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
|
||||||
|
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
|
||||||
|
.expect("ace moves to foundation");
|
||||||
|
game.move_cards(
|
||||||
|
KlondikePile::Tableau(two_from),
|
||||||
|
KlondikePile::Tableau(run_on),
|
||||||
|
1,
|
||||||
|
)
|
||||||
|
.expect("two stacks onto three");
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_move_for_run_moves_exact_clicked_run_not_top_card() {
|
||||||
|
let game = deal_run_with_foundation_bait(51, Tableau::Tableau6, Tableau::Tableau5);
|
||||||
|
let run_pile = KlondikePile::Tableau(Tableau::Tableau5);
|
||||||
|
let cards = game.pile(run_pile);
|
||||||
|
let (top, _) = cards.last().cloned().expect("run pile has cards");
|
||||||
|
let (clicked, _) = cards[cards.len() - 2].clone();
|
||||||
|
|
||||||
|
// The bait: the lone top card has a foundation move available.
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
best_destination(&top, &game),
|
||||||
|
Some(KlondikePile::Foundation(_))
|
||||||
|
),
|
||||||
|
"precondition: run top card must be foundation-eligible"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clicking the run base must move exactly the 2-card run to a tableau.
|
||||||
|
match auto_move_for_run(&clicked, &run_pile, &game, 2) {
|
||||||
|
Some((KlondikePile::Tableau(dest), 2)) => assert_ne!(dest, Tableau::Tableau5),
|
||||||
|
other => panic!("expected a whole-run tableau move, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_move_for_run_rejects_when_clicked_run_cannot_move() {
|
||||||
|
let game = deal_run_with_foundation_bait(145, Tableau::Tableau4, Tableau::Tableau7);
|
||||||
|
let run_pile = KlondikePile::Tableau(Tableau::Tableau7);
|
||||||
|
let cards = game.pile(run_pile);
|
||||||
|
let (top, _) = cards.last().cloned().expect("run pile has cards");
|
||||||
|
let (clicked, _) = cards[cards.len() - 2].clone();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
best_destination(&top, &game),
|
||||||
|
Some(KlondikePile::Foundation(_))
|
||||||
|
),
|
||||||
|
"precondition: run top card must be foundation-eligible"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The 2-card run has no legal home — the top card's foundation move
|
||||||
|
// must NOT be taken as a substitute.
|
||||||
|
assert_eq!(
|
||||||
|
auto_move_for_run(&clicked, &run_pile, &game, 2),
|
||||||
|
None,
|
||||||
|
"an immovable clicked run must not fall back to a top-card move"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn auto_move_for_run_single_card_prefers_foundation() {
|
||||||
|
// Seed 51 after the Ace reaches the foundation: the Two on tableau 6
|
||||||
|
// is a lone face-up card that could go to the foundation OR onto the
|
||||||
|
// Three on tableau 5. Foundation must win.
|
||||||
|
let mut game = GameState::new(51, DrawStockConfig::DrawOne);
|
||||||
|
let (ace, _) = game
|
||||||
|
.pile(KlondikePile::Tableau(Tableau::Tableau1))
|
||||||
|
.last()
|
||||||
|
.cloned()
|
||||||
|
.expect("seed 51 deals a card on tableau 1");
|
||||||
|
let foundation = best_destination(&ace, &game).expect("ace has a foundation home");
|
||||||
|
game.move_cards(KlondikePile::Tableau(Tableau::Tableau1), foundation, 1)
|
||||||
|
.expect("ace moves to foundation");
|
||||||
|
|
||||||
|
let two_pile = KlondikePile::Tableau(Tableau::Tableau6);
|
||||||
|
let (two, _) = game.pile(two_pile).last().cloned().expect("two on top");
|
||||||
|
assert!(
|
||||||
|
game.can_move_cards(&two_pile, &KlondikePile::Tableau(Tableau::Tableau5), 1),
|
||||||
|
"precondition: a tableau destination also exists"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
auto_move_for_run(&two, &two_pile, &game, 1),
|
||||||
|
Some((KlondikePile::Foundation(_), 1))
|
||||||
|
),
|
||||||
|
"a lone top card still prefers the foundation"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Task #28 — find_hint pure-function tests
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn find_hint_finds_ace_to_foundation() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Place Ace of Clubs on top of tableau 0.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
let ace_clubs = Card::new(D::Deck1, Suit::Clubs, Rank::Ace);
|
||||||
|
game.set_test_tableau_cards(Tableau::Tableau1, vec![ace_clubs]);
|
||||||
|
|
||||||
|
let hint = find_hint(&game);
|
||||||
|
assert!(hint.is_some(), "should find a hint");
|
||||||
|
let (from, to) = hint.unwrap();
|
||||||
|
assert_eq!(from, KlondikePile::Tableau(Tableau::Tableau1));
|
||||||
|
assert_eq!(to, KlondikePile::Foundation(Foundation::Foundation1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// G key fires ForfeitRequestEvent (modal-based forfeit flow)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// `handle_keyboard_forfeit` only checks `paused` and the G keypress;
|
||||||
|
/// the "is there actually a game?" gating lives in
|
||||||
|
/// `pause_plugin::handle_forfeit_request` so it can surface a
|
||||||
|
/// "No game to forfeit" toast instead of failing silently.
|
||||||
|
#[test]
|
||||||
|
fn g_key_paused_check_keeps_handler_silent_while_pause_modal_owns_input() {
|
||||||
|
// Build the system param state by hand so we don't rely on a
|
||||||
|
// full Bevy app: the assertion is that the function returns
|
||||||
|
// early on the paused branch without calling write_message.
|
||||||
|
// This is verified by the plain `if paused { return; }` shape;
|
||||||
|
// the body is small enough to inspect by reading.
|
||||||
|
// (Higher-level integration coverage lives in the pause-plugin
|
||||||
|
// tests where `forfeit_app` simulates the full flow.)
|
||||||
|
let _ = handle_keyboard_forfeit; // proves the symbol still compiles
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// all_hints / new-game window — pure-function tests added during refactor
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pass 3 of `all_hints` should suggest drawing from the stock when there
|
||||||
|
/// are no other moves and the stock is non-empty.
|
||||||
|
#[test]
|
||||||
|
fn all_hints_suggests_draw_when_no_moves_and_stock_nonempty() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
let mut game = GameState::new(1, DrawStockConfig::DrawOne);
|
||||||
|
|
||||||
|
// Remove all foundation, tableau, and waste cards so no pile-to-pile
|
||||||
|
// move exists. Leave one card in the stock.
|
||||||
|
clear_test_piles(&mut game);
|
||||||
|
// Put one card back into the stock so "draw" is a valid suggestion.
|
||||||
|
game.set_test_stock_cards(vec![Card::new(D::Deck1, Suit::Clubs, Rank::Ace)]);
|
||||||
|
|
||||||
|
let hints = all_hints(&game);
|
||||||
|
assert_eq!(hints.len(), 1, "exactly one hint: draw from stock");
|
||||||
|
let (from, to) = &hints[0];
|
||||||
|
assert_eq!(*from, KlondikePile::Stock, "hint must come from Stock");
|
||||||
|
assert_eq!(*to, KlondikePile::Stock, "hint must point to Waste");
|
||||||
|
}
|
||||||
|
|
||||||
|
// `all_hints` must be empty when both stock and waste are empty and no
|
||||||
|
// pile-to-pile move exists — the game is truly stuck.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Drag-rejection return tween — `CardAnimation` replaces the legacy
|
||||||
|
// `ShakeAnim` on the dragged cards. The audio cue
|
||||||
|
// (`card_invalid.wav` via `MoveRejectedEvent`) is unchanged; only the
|
||||||
|
// visual response on the dragged cards swapped from a horizontal wiggle
|
||||||
|
// to a smooth ease-out glide back to the origin pile.
|
||||||
|
//
|
||||||
|
// These tests build the component values exactly as `end_drag` and
|
||||||
|
// `touch_end_drag` would, then assert the resulting `CardAnimation` is
|
||||||
|
// shaped correctly. Driving `end_drag` end-to-end requires a real window
|
||||||
|
// and mouse-button input, so we exercise the data path the same way the
|
||||||
|
// legacy `ShakeAnim` tests did.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Helper: build the `CardAnimation` the rejection paths construct for
|
||||||
|
/// one dragged card. Mirrors the inline logic in `end_drag` and
|
||||||
|
/// `touch_end_drag` so the tests stay in sync with the production code.
|
||||||
|
fn build_drag_reject_animation(
|
||||||
|
drag_pos: Vec2,
|
||||||
|
drag_z: f32,
|
||||||
|
target_pos: Vec2,
|
||||||
|
stack_index: usize,
|
||||||
|
) -> CardAnimation {
|
||||||
|
let end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
|
CardAnimation::slide(drag_pos, drag_z, target_pos, end_z, MotionCurve::Responsive)
|
||||||
|
.with_duration(MOTION_DRAG_REJECT_SECS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every card in `drag.cards` should receive its own `CardAnimation` on
|
||||||
|
/// rejection. With the shake → tween migration, the assertion changes
|
||||||
|
/// from "every dragged card gets a ShakeAnim" to "every dragged card
|
||||||
|
/// gets a CardAnimation" — same coverage, new component.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_inserts_card_animation_on_each_dragged_card() {
|
||||||
|
use solitaire_core::Deck as D;
|
||||||
|
use solitaire_core::{Card, Rank, Suit};
|
||||||
|
// Simulate a stack drag of two cards.
|
||||||
|
let dragged_cards: Vec<Card> = vec![
|
||||||
|
Card::new(D::Deck1, Suit::Hearts, Rank::King),
|
||||||
|
Card::new(D::Deck1, Suit::Spades, Rank::Queen),
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut animated: Vec<Card> = Vec::new();
|
||||||
|
for card in &dragged_cards {
|
||||||
|
// In `end_drag` we iterate `drag.cards` and look up each card in
|
||||||
|
// `card_entities`. The cards we would insert a `CardAnimation` on
|
||||||
|
// must exactly match the dragged set.
|
||||||
|
animated.push(card.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
animated, dragged_cards,
|
||||||
|
"every card in drag.cards must receive a CardAnimation on rejection"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `end` field of the inserted tween must equal the card's resting
|
||||||
|
/// slot in its origin pile — the position the card belongs at after a
|
||||||
|
/// rejected drop. Without this, the tween would glide to the wrong spot
|
||||||
|
/// and `sync_cards` would have to fight it back.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_targets_origin_resting_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0); // somewhere mid-screen
|
||||||
|
let target_pos = Vec2::new(123.5, -50.0); // origin pile slot
|
||||||
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 3);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.end - target_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.end must match the origin slot's resting position. \
|
||||||
|
Expected {target_pos:?}, got {:?}",
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start` field of the inserted tween must equal the card's
|
||||||
|
/// drop-time transform position — i.e. wherever the cursor or finger
|
||||||
|
/// released the card. This is what makes the glide feel like a
|
||||||
|
/// continuous return rather than a teleport-then-shake.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_starts_from_drag_position() {
|
||||||
|
let drag_pos = Vec2::new(640.0, 200.0);
|
||||||
|
let target_pos = Vec2::new(80.0, -120.0);
|
||||||
|
let anim = build_drag_reject_animation(drag_pos, DRAG_Z, target_pos, /* stack_index */ 0);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(anim.start - drag_pos).length() < 1e-6,
|
||||||
|
"CardAnimation.start must match the drop-time transform position \
|
||||||
|
(where the cursor released). Expected {drag_pos:?}, got {:?}",
|
||||||
|
anim.start
|
||||||
|
);
|
||||||
|
// And the start must be visibly distinct from the origin slot — the
|
||||||
|
// whole point of the tween is that it visibly travels.
|
||||||
|
assert!(
|
||||||
|
(anim.start - anim.end).length() > 1.0,
|
||||||
|
"rejected drag should travel a visible distance, got start={:?} end={:?}",
|
||||||
|
anim.start,
|
||||||
|
anim.end
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tween duration is taken from the project-wide motion token so
|
||||||
|
/// designers can retune the feel from one place. Keeps the constant and
|
||||||
|
/// the call site honest.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_correct_duration() {
|
||||||
|
let anim =
|
||||||
|
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
||||||
|
assert!(
|
||||||
|
(anim.duration - MOTION_DRAG_REJECT_SECS).abs() < 1e-6,
|
||||||
|
"drag-rejection tween duration must match MOTION_DRAG_REJECT_SECS \
|
||||||
|
({MOTION_DRAG_REJECT_SECS}), got {}",
|
||||||
|
anim.duration
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The curve must be a no-overshoot ease-out so the card decelerates
|
||||||
|
/// cleanly into its rest position — overshoot on a rejection feels
|
||||||
|
/// jittery rather than forgiving.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_uses_responsive_curve() {
|
||||||
|
let anim =
|
||||||
|
build_drag_reject_animation(Vec2::new(640.0, 200.0), DRAG_Z, Vec2::new(80.0, -120.0), 0);
|
||||||
|
assert_eq!(
|
||||||
|
anim.curve,
|
||||||
|
MotionCurve::Responsive,
|
||||||
|
"drag-rejection tween must use Responsive (quintic ease-out) \
|
||||||
|
so the card snaps back without bouncing past the slot"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `start_z` of the tween must equal the card's drop-time z
|
||||||
|
/// (`DRAG_Z`) so the card stays above the rest of the table while it
|
||||||
|
/// travels home, then settles at the correct resting z.
|
||||||
|
#[test]
|
||||||
|
fn rejected_drag_animation_lifts_from_drag_z_to_resting_z() {
|
||||||
|
let stack_index = 2_usize;
|
||||||
|
let anim = build_drag_reject_animation(
|
||||||
|
Vec2::new(640.0, 200.0),
|
||||||
|
DRAG_Z,
|
||||||
|
Vec2::new(80.0, -120.0),
|
||||||
|
stack_index,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(anim.start_z - DRAG_Z).abs() < 1e-6,
|
||||||
|
"tween must start at DRAG_Z so the card stays on top during the glide"
|
||||||
|
);
|
||||||
|
let expected_end_z = 1.0 + (stack_index as f32) * STACK_FAN_FRAC;
|
||||||
|
assert!(
|
||||||
|
(anim.end_z - expected_end_z).abs() < 1e-6,
|
||||||
|
"tween must end at the slot's resting z, got {} expected {expected_end_z}",
|
||||||
|
anim.end_z
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Hint system — async port (v0.18.0+)
|
||||||
|
//
|
||||||
|
// `handle_keyboard_hint` no longer runs the solver inline; it
|
||||||
|
// spawns an `AsyncComputeTaskPool` task whose result the polling
|
||||||
|
// system in `pending_hint` turns into hint visuals one frame
|
||||||
|
// later. The behaviour contract this section pins is "pressing H
|
||||||
|
// populates `PendingHintTask`" — the spawn-to-emit pipeline is
|
||||||
|
// covered end-to-end in `pending_hint::tests`.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Pressing H on a non-paused, non-won game with a live
|
||||||
|
/// `GameStateResource` + `LayoutResource` must populate
|
||||||
|
/// `PendingHintTask`. The polling system, exercised in
|
||||||
|
/// `pending_hint::tests`, drives the result to a visual event.
|
||||||
|
#[test]
|
||||||
|
fn pressing_h_spawns_pending_hint_task() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<HintVisualEvent>();
|
||||||
|
app.init_resource::<HintCycleIndex>();
|
||||||
|
app.init_resource::<HintSolverConfig>();
|
||||||
|
app.init_resource::<crate::pending_hint::PendingHintTask>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.insert_resource(LayoutResource(compute_layout(
|
||||||
|
Vec2::new(1280.0, 800.0),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
app.insert_resource(GameStateResource(GameState::new(
|
||||||
|
42,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
)));
|
||||||
|
app.add_systems(Update, handle_keyboard_hint);
|
||||||
|
|
||||||
|
// Simulate the H key being pressed this frame.
|
||||||
|
{
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(KeyCode::KeyH);
|
||||||
|
input.clear();
|
||||||
|
input.press(KeyCode::KeyH);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<crate::pending_hint::PendingHintTask>()
|
||||||
|
.is_pending(),
|
||||||
|
"pressing H must spawn an async hint task",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Pointer-drag / keyboard mutual exclusion.
|
||||||
|
//
|
||||||
|
// A state mutation fired while `follow_drag` is writing the dragged cards'
|
||||||
|
// transforms every frame would make the card re-sync insert a `CardAnim`
|
||||||
|
// on the same entities (two systems fighting over `Transform`) and leave
|
||||||
|
// `DragState`'s origin indices stale. `handle_keyboard_core` and
|
||||||
|
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
|
||||||
|
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
|
||||||
|
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
|
||||||
|
// drops that lift cleanly when the state moves.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn keyboard_core_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<UndoRequestEvent>();
|
||||||
|
app.add_message::<NewGameRequestEvent>();
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<DrawRequestEvent>();
|
||||||
|
app.add_message::<StartZenRequestEvent>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.add_systems(Update, handle_keyboard_core);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_key(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_count<M: Message>(app: &App) -> usize {
|
||||||
|
let messages = app.world().resource::<Messages<M>>();
|
||||||
|
let mut cursor = messages.get_cursor();
|
||||||
|
cursor.read(messages).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
|
||||||
|
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||||
|
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
|
||||||
|
drag.committed = true;
|
||||||
|
drag.active_touch_id = active_touch_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_fires_undo_when_no_drag_active() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
1,
|
||||||
|
"U with an idle DragState must fire UndoRequestEvent",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_ignored_during_committed_mouse_drag() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(&mut app, None); // mouse drag
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"U during a mouse drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_key_ignored_during_touch_drag() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(&mut app, Some(7)); // real touch id
|
||||||
|
press_key(&mut app, KeyCode::KeyD);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<DrawRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"D during a touch drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_allowed_during_keyboard_lift() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(
|
||||||
|
&mut app,
|
||||||
|
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
|
||||||
|
);
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
1,
|
||||||
|
"the keyboard-lift sentinel must not block core shortcuts",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn g_key_forfeit_ignored_during_mouse_drag() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<ForfeitRequestEvent>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.add_systems(Update, handle_keyboard_forfeit);
|
||||||
|
|
||||||
|
set_drag(&mut app, None);
|
||||||
|
press_key(&mut app, KeyCode::KeyG);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<ForfeitRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"G during a mouse drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||||
spawn_modal_button, spawn_modal_header,
|
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY,
|
||||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||||
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
|
Z_PAUSE_DIALOG,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -158,18 +158,38 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
poll_leaderboard_fetch,
|
poll_leaderboard_fetch,
|
||||||
update_leaderboard_panel,
|
update_leaderboard_panel,
|
||||||
handle_opt_in_button,
|
handle_opt_in_button,
|
||||||
poll_opt_in_task,
|
// The polls share the warning-toast stream with the
|
||||||
|
// daily-expiry check (drained wholesale, #143).
|
||||||
|
poll_opt_in_task
|
||||||
|
.in_set(crate::game_plugin::WarningToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::WarningToastWriters),
|
||||||
handle_opt_out_button,
|
handle_opt_out_button,
|
||||||
poll_opt_out_task,
|
poll_opt_out_task
|
||||||
|
.in_set(crate::game_plugin::WarningToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::WarningToastWriters),
|
||||||
handle_set_display_name_button,
|
handle_set_display_name_button,
|
||||||
handle_display_name_text_input,
|
handle_display_name_text_input,
|
||||||
handle_display_name_confirm,
|
handle_display_name_confirm,
|
||||||
handle_display_name_cancel,
|
handle_display_name_cancel,
|
||||||
update_leaderboard_public_name_label,
|
// Writes one marker-filtered Text label — disjoint from
|
||||||
|
// the HUD/stats text painters (UiTextFx club, #143).
|
||||||
|
update_leaderboard_public_name_label
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
// Opt-in state and display name live in
|
||||||
|
// SettingsResource — the whole chain joins the
|
||||||
|
// screen-plugin settings club (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.add_systems(Update, scroll_leaderboard_panel);
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
scroll_leaderboard_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -343,13 +363,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 {
|
||||||
@@ -528,11 +556,6 @@ fn spawn_leaderboard_screen(
|
|||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let font_status = TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
let font_row = TextFont {
|
let font_row = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
font_size: TYPE_BODY,
|
font_size: TYPE_BODY,
|
||||||
@@ -639,30 +662,31 @@ fn spawn_leaderboard_screen(
|
|||||||
.with_children(|body| {
|
.with_children(|body| {
|
||||||
match data {
|
match data {
|
||||||
LeaderboardResource::Idle => {
|
LeaderboardResource::Idle => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Fetching\u{2026}"),
|
body,
|
||||||
font_status.clone(),
|
"\u{21BB}",
|
||||||
TextColor(STATE_INFO),
|
"Fetching the leaderboard\u{2026}",
|
||||||
));
|
None,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
LeaderboardResource::Error(_) => {
|
LeaderboardResource::Error(_) => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
body,
|
||||||
font_status.clone(),
|
"!",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Couldn't reach the leaderboard.",
|
||||||
));
|
Some("Try again later."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Be the first on the leaderboard."),
|
body,
|
||||||
font_status.clone(),
|
"\u{2660}",
|
||||||
TextColor(TEXT_PRIMARY),
|
"Be the first on the leaderboard.",
|
||||||
));
|
Some("Win a game and opt in to appear here."),
|
||||||
body.spawn((
|
font_res,
|
||||||
Text::new("Win a game and opt in to appear here."),
|
);
|
||||||
font_row.clone(),
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
LeaderboardResource::Loaded(rows) => {
|
LeaderboardResource::Loaded(rows) => {
|
||||||
// Column headers
|
// Column headers
|
||||||
@@ -862,10 +886,8 @@ fn handle_display_name_confirm(
|
|||||||
.leaderboard_display_name
|
.leaderboard_display_name
|
||||||
.clone()
|
.clone()
|
||||||
.unwrap_or_else(|| {
|
.unwrap_or_else(|| {
|
||||||
if let SyncBackend::SolitaireServer {
|
if let SyncBackend::SolitaireServer { ref username, .. } =
|
||||||
ref username,
|
settings.0.sync_backend
|
||||||
..
|
|
||||||
} = settings.0.sync_backend
|
|
||||||
{
|
{
|
||||||
username.chars().take(32).collect()
|
username.chars().take(32).collect()
|
||||||
} else {
|
} else {
|
||||||
@@ -890,12 +912,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 {
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub mod avatar_plugin;
|
|||||||
pub mod card_animation;
|
pub mod card_animation;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
|
pub mod cheat_sheet_plugin;
|
||||||
|
pub mod contextual_tips_plugin;
|
||||||
pub mod core_game_plugin;
|
pub mod core_game_plugin;
|
||||||
pub mod cursor_plugin;
|
pub mod cursor_plugin;
|
||||||
pub mod daily_challenge_plugin;
|
pub mod daily_challenge_plugin;
|
||||||
@@ -26,6 +28,7 @@ pub mod font_plugin;
|
|||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
pub mod help_plugin;
|
pub mod help_plugin;
|
||||||
pub mod home_plugin;
|
pub mod home_plugin;
|
||||||
|
pub mod hotkeys;
|
||||||
pub mod hud_plugin;
|
pub mod hud_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
@@ -43,8 +46,10 @@ pub mod replay_overlay;
|
|||||||
pub mod replay_playback;
|
pub mod replay_playback;
|
||||||
pub mod resources;
|
pub mod resources;
|
||||||
pub mod safe_area;
|
pub mod safe_area;
|
||||||
|
mod schedule_checks;
|
||||||
pub mod selection_plugin;
|
pub mod selection_plugin;
|
||||||
pub mod settings_plugin;
|
pub mod settings_plugin;
|
||||||
|
pub mod solution_playback_plugin;
|
||||||
pub mod splash_plugin;
|
pub mod splash_plugin;
|
||||||
pub mod stats_plugin;
|
pub mod stats_plugin;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
@@ -53,14 +58,19 @@ pub mod sync_plugin;
|
|||||||
pub mod sync_setup_plugin;
|
pub mod sync_setup_plugin;
|
||||||
pub mod table_plugin;
|
pub mod table_plugin;
|
||||||
pub mod theme;
|
pub mod theme;
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub mod theme_store_plugin;
|
||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod touch_selection_plugin;
|
pub mod touch_selection_plugin;
|
||||||
pub mod ui_focus;
|
pub mod ui_focus;
|
||||||
|
pub mod ui_glass;
|
||||||
pub mod ui_modal;
|
pub mod ui_modal;
|
||||||
pub mod ui_theme;
|
pub mod ui_theme;
|
||||||
pub mod ui_tooltip;
|
pub mod ui_tooltip;
|
||||||
pub mod weekly_goals_plugin;
|
pub mod weekly_goals_plugin;
|
||||||
|
pub mod whats_new_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"))]
|
||||||
@@ -78,9 +88,8 @@ pub use avatar_plugin::{AvatarFetchEvent, AvatarPlugin, AvatarResource};
|
|||||||
pub use card_animation::{
|
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, compute_duration, micro_vary,
|
||||||
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
|
sample_curve,
|
||||||
sample_curve, win_scatter_targets,
|
|
||||||
};
|
};
|
||||||
pub use card_plugin::{
|
pub use card_plugin::{
|
||||||
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
|
||||||
@@ -89,6 +98,8 @@ pub use card_plugin::{
|
|||||||
pub use challenge_plugin::{
|
pub use challenge_plugin::{
|
||||||
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
||||||
};
|
};
|
||||||
|
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
|
||||||
|
pub use contextual_tips_plugin::ContextualTipsPlugin;
|
||||||
pub use core_game_plugin::CoreGamePlugin;
|
pub use core_game_plugin::CoreGamePlugin;
|
||||||
pub use cursor_plugin::CursorPlugin;
|
pub use cursor_plugin::CursorPlugin;
|
||||||
pub use daily_challenge_plugin::{
|
pub use daily_challenge_plugin::{
|
||||||
@@ -102,7 +113,7 @@ pub use events::{
|
|||||||
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
HintVisualEvent, InfoToastEvent, ManualSyncRequestEvent, MoveRejectedEvent, MoveRequestEvent,
|
||||||
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
NewGameRequestEvent, PauseRequestEvent, StartChallengeRequestEvent,
|
||||||
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
|
||||||
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent, SyncCompleteEvent,
|
StartTimeAttackRequestEvent, StartZenRequestEvent, StateChangedEvent,
|
||||||
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent,
|
||||||
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
ToggleSettingsRequestEvent, ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
|
||||||
XpAwardedEvent,
|
XpAwardedEvent,
|
||||||
@@ -158,6 +169,7 @@ pub use settings_plugin::{
|
|||||||
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
|
||||||
};
|
};
|
||||||
pub use solitaire_data::SyncProvider;
|
pub use solitaire_data::SyncProvider;
|
||||||
|
pub use solution_playback_plugin::{SolutionPlayback, SolutionPlaybackPlugin, SolutionSolveTask};
|
||||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||||
pub use stats_plugin::{
|
pub use stats_plugin::{
|
||||||
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
|
LatestReplayPath, ReplayHistoryResource, ReplayNextButton, ReplayPrevButton,
|
||||||
@@ -175,6 +187,8 @@ pub use theme::{
|
|||||||
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
ActiveTheme, CardTheme, CardThemeLoader, ThemeEntry, ThemePlugin, ThemeRegistry,
|
||||||
ThemeRegistryPlugin, set_theme,
|
ThemeRegistryPlugin, set_theme,
|
||||||
};
|
};
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub use theme_store_plugin::{ThemeStorePlugin, ThemeStoreScreen};
|
||||||
pub use time_attack_plugin::{
|
pub use time_attack_plugin::{
|
||||||
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
TIME_ATTACK_DURATION_SECS, TimeAttackEndedEvent, TimeAttackPlugin, TimeAttackResource,
|
||||||
};
|
};
|
||||||
@@ -187,6 +201,8 @@ pub use ui_modal::{
|
|||||||
};
|
};
|
||||||
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
|
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
|
||||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||||
|
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
|
||||||
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};
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ use solitaire_data::{Settings, save_settings_to};
|
|||||||
|
|
||||||
use crate::font_plugin::FontResource;
|
use crate::font_plugin::FontResource;
|
||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
|
use crate::splash_plugin::SplashRoot;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_body_text, spawn_modal_button,
|
||||||
spawn_modal_header,
|
spawn_modal_header,
|
||||||
@@ -36,7 +37,6 @@ use crate::ui_theme::{
|
|||||||
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, TEXT_PRIMARY, TYPE_BODY, TYPE_CAPTION,
|
||||||
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
};
|
};
|
||||||
use crate::splash_plugin::SplashRoot;
|
|
||||||
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
use crate::ui_theme::{TEXT_SECONDARY, Z_ONBOARDING};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
|
|||||||
pub struct OnboardingSlideIndex(pub u8);
|
pub struct OnboardingSlideIndex(pub u8);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
|
// Slide data — the hotkey slide renders the essential subset of the
|
||||||
// two screens stay in sync without a shared abstraction.
|
// shared table in `crate::hotkeys` (Phase J unified the previously
|
||||||
|
// diverging copies here and in help_plugin).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// A single `key — description` pair shown on slide 3.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
struct HotkeyRow {
|
|
||||||
keys: &'static str,
|
|
||||||
description: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Most-used shortcuts from the `help_plugin` canonical list.
|
|
||||||
///
|
|
||||||
/// Updating the list in `help_plugin.rs` should be mirrored here. The
|
|
||||||
/// ARCHITECTURE.md decision log calls out that we copy values rather than
|
|
||||||
/// refactor the help plugin.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
const HOTKEYS: &[HotkeyRow] = &[
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "D / Space",
|
|
||||||
description: "Draw from stock",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "U",
|
|
||||||
description: "Undo last move",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "Tab → Enter",
|
|
||||||
description: "Pick a card; arrows pick where; Enter to drop",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "N",
|
|
||||||
description: "New Classic game",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "M",
|
|
||||||
description: "Open Mode Launcher (then 1–5 to pick)",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "S",
|
|
||||||
description: "Stats & progression",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "A",
|
|
||||||
description: "Achievements",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "O",
|
|
||||||
description: "Settings",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "Esc",
|
|
||||||
description: "Pause / resume",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "F1",
|
|
||||||
description: "Help / controls",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -153,12 +98,26 @@ pub struct OnboardingPlugin;
|
|||||||
|
|
||||||
impl Plugin for OnboardingPlugin {
|
impl Plugin for OnboardingPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<OnboardingSlideIndex>()
|
app.init_resource::<OnboardingSlideIndex>().add_systems(
|
||||||
.add_systems(Update, spawn_if_first_run)
|
Update,
|
||||||
.add_systems(
|
// One chain: all three touch the slide index and the
|
||||||
Update,
|
// first-run flag in SettingsResource. Club membership
|
||||||
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
|
// declares the settings access disjoint from the other
|
||||||
);
|
// screen plugins'; the keyboard handler runs before
|
||||||
|
// FocusKeys like every other Esc consumer (#143).
|
||||||
|
(
|
||||||
|
spawn_if_first_run,
|
||||||
|
handle_onboarding_buttons,
|
||||||
|
handle_onboarding_keyboard.before(crate::ui_focus::FocusKeys),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
// Before the settings spine: the first-run flag write
|
||||||
|
// lands before every same-frame settings reader (they
|
||||||
|
// all sit at or after SettingsMutation transitively).
|
||||||
|
.before(crate::settings_plugin::SettingsMutation)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +272,10 @@ fn complete_onboarding(
|
|||||||
despawn_screen(commands, screens);
|
despawn_screen(commands, screens);
|
||||||
if let Some(s) = settings {
|
if let Some(s) = settings {
|
||||||
s.0.first_run_complete = true;
|
s.0.first_run_complete = true;
|
||||||
|
// A fresh install has nothing "new" to announce — stamp the
|
||||||
|
// running release so the What's-new card (Phase I) only ever
|
||||||
|
// fires after an actual upgrade.
|
||||||
|
s.0.last_seen_whats_new = crate::whats_new_plugin::current_release_version();
|
||||||
persist(path.map(|p| &p.0), &s.0);
|
persist(path.map(|p| &p.0), &s.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,20 +345,27 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How-to-play body copy, phrased for the platform's input vocabulary —
|
||||||
|
/// a touch player never left-clicks (Phase H polish; spotted in the
|
||||||
|
/// emulator smoke of v0.44.0).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \
|
||||||
|
You can drag a whole column at once by grabbing the topmost card \
|
||||||
|
you want to move. Double-tap a face-up card to send it to a \
|
||||||
|
foundation pile automatically (when the move is legal). \
|
||||||
|
Tap Hint in the bottom bar for a suggested move.";
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \
|
||||||
|
You can drag a whole column at once by grabbing the topmost card \
|
||||||
|
you want to move. Double-click a face-up card to send it to a \
|
||||||
|
foundation pile automatically (when the move is legal). \
|
||||||
|
Right-click a card for a hint — valid destinations will highlight.";
|
||||||
|
|
||||||
/// Slide 2 — How to play.
|
/// Slide 2 — How to play.
|
||||||
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
spawn_modal_header(card, "Drag cards to play", font_res);
|
spawn_modal_header(card, "Drag cards to play", font_res);
|
||||||
spawn_modal_body_text(
|
spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
|
||||||
card,
|
|
||||||
"Left-click and drag any face-up card to move it between piles. \
|
|
||||||
You can drag a whole column at once by grabbing the topmost card \
|
|
||||||
you want to move. Double-click a face-up card to send it to a \
|
|
||||||
foundation pile automatically (when the move is legal). \
|
|
||||||
Right-click a card for a hint — valid destinations will highlight.",
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
spawn_modal_actions(card, |actions| {
|
spawn_modal_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
@@ -435,8 +405,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
||||||
|
|
||||||
// Vertical list of `key — description` rows, same chip style as HelpScreen.
|
// Vertical list of `key — description` rows, same chip style as
|
||||||
for row in HOTKEYS {
|
// HelpScreen. Essential subset only — the full table lives on
|
||||||
|
// the hold-`/` cheat sheet.
|
||||||
|
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
|
||||||
card.spawn(Node {
|
card.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
@@ -815,22 +787,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Hotkey list is non-empty (guards against accidental truncation)
|
// Hotkey slide renders a non-empty essential subset (the table's own
|
||||||
|
// integrity tests live in `crate::hotkeys`)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hotkey_list_is_non_empty() {
|
fn hotkey_slide_subset_is_non_empty() {
|
||||||
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
|
assert!(
|
||||||
}
|
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
|
||||||
|
"the onboarding slide needs at least one essential hotkey"
|
||||||
#[test]
|
);
|
||||||
fn all_hotkey_rows_have_non_empty_fields() {
|
|
||||||
for row in HOTKEYS {
|
|
||||||
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
|
|
||||||
assert!(
|
|
||||||
!row.description.is_empty(),
|
|
||||||
"hotkey description must not be empty"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ struct PauseResumeButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct PauseForfeitButton;
|
struct PauseForfeitButton;
|
||||||
|
|
||||||
|
/// Marker on the "Show solution" secondary button on the pause modal.
|
||||||
|
/// A click resumes the game and fires `ShowSolutionRequestEvent`;
|
||||||
|
/// `solution_playback_plugin` takes it from there.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct PauseSolutionButton;
|
||||||
|
|
||||||
/// Marker on the forfeit-confirm modal scrim.
|
/// Marker on the forfeit-confirm modal scrim.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct ForfeitConfirmScreen;
|
pub struct ForfeitConfirmScreen;
|
||||||
@@ -98,6 +104,15 @@ pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
|
|||||||
/// to disk, and routes the Forfeit confirm-modal flow.
|
/// to disk, and routes the Forfeit confirm-modal flow.
|
||||||
pub struct PausePlugin;
|
pub struct PausePlugin;
|
||||||
|
|
||||||
|
/// Set wrapping every system that mutates [`PausedResource`] or drives the
|
||||||
|
/// pause/forfeit modal flow. Runs as one deterministic chain between
|
||||||
|
/// [`crate::settings_plugin::SettingsMutation`] (the pause overlay's
|
||||||
|
/// draw-mode toggle writes `SettingsResource`) and
|
||||||
|
/// [`crate::game_plugin::GameMutation`], so every system ordered after
|
||||||
|
/// `GameMutation` observes this frame's pause state transitively (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PauseMutation;
|
||||||
|
|
||||||
impl Plugin for PausePlugin {
|
impl Plugin for PausePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// add_message is idempotent — other plugins may register these
|
// add_message is idempotent — other plugins may register these
|
||||||
@@ -107,29 +122,58 @@ impl Plugin for PausePlugin {
|
|||||||
.add_message::<PauseRequestEvent>()
|
.add_message::<PauseRequestEvent>()
|
||||||
.add_message::<ForfeitRequestEvent>()
|
.add_message::<ForfeitRequestEvent>()
|
||||||
.add_message::<ForfeitEvent>()
|
.add_message::<ForfeitEvent>()
|
||||||
|
.add_message::<crate::events::ShowSolutionRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.init_resource::<PausedResource>()
|
.init_resource::<PausedResource>()
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
PauseMutation
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
// Before the pre-mutation game-state spine: the elapsed
|
||||||
|
// timer must observe this frame's pause transition, and
|
||||||
|
// the draw-mode toggle's settings write must land before
|
||||||
|
// sync_settings_to_game forwards it.
|
||||||
|
.before(crate::game_plugin::PreMutation)
|
||||||
|
.before(crate::game_plugin::GameMutation)
|
||||||
|
// toggle_pause must see SelectionState *before*
|
||||||
|
// handle_selection_keys clears it, so it can skip Escape
|
||||||
|
// when a card is selected. The other members mutate
|
||||||
|
// PausedResource, which the selection keys read.
|
||||||
|
.before(SelectionKeySet)
|
||||||
|
// HUD buttons read PausedResource (e.g. hint is inert
|
||||||
|
// while paused) — order them after this frame's pause
|
||||||
|
// transition.
|
||||||
|
.before(crate::hud_plugin::HudButtons),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
// toggle_pause must see SelectionState *before* handle_selection_keys
|
// toggle_pause must run *before* handle_forfeit_keyboard
|
||||||
// clears it, so it can skip Escape when a card is selected.
|
// so the ForfeitConfirmScreen is still alive when
|
||||||
// It must also run *before* handle_forfeit_keyboard so the
|
// toggle_pause's early-return guard checks for it —
|
||||||
// ForfeitConfirmScreen is still alive when toggle_pause's
|
// otherwise an Esc that closes the forfeit modal would
|
||||||
// early-return guard checks for it — otherwise an Esc that
|
// also open pause in the same frame. The chain preserves
|
||||||
// closes the forfeit modal would also open pause in the
|
// that edge and makes the whole pause/forfeit flow
|
||||||
// same frame.
|
// deterministic (all members share PausedResource and
|
||||||
|
// the forfeit message streams).
|
||||||
|
// Reads only motion/display settings fields — disjoint
|
||||||
|
// from the screen-plugin club's writes (#143).
|
||||||
toggle_pause
|
toggle_pause
|
||||||
.before(SelectionKeySet)
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
.before(handle_forfeit_keyboard),
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
handle_pause_draw_buttons,
|
auto_resume_on_overlay,
|
||||||
|
handle_pause_draw_buttons
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
handle_pause_resume_button,
|
handle_pause_resume_button,
|
||||||
handle_pause_forfeit_button,
|
handle_pause_forfeit_button,
|
||||||
|
handle_pause_solution_button,
|
||||||
handle_forfeit_request,
|
handle_forfeit_request,
|
||||||
handle_forfeit_confirm_buttons,
|
|
||||||
handle_forfeit_keyboard,
|
handle_forfeit_keyboard,
|
||||||
auto_resume_on_overlay,
|
handle_forfeit_confirm_buttons,
|
||||||
),
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(PauseMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -304,6 +348,22 @@ fn handle_pause_resume_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Translates a click on the pause modal's "Show solution" button into
|
||||||
|
/// a resume (`PauseRequestEvent` — playback can't run while paused)
|
||||||
|
/// plus a `ShowSolutionRequestEvent` for `solution_playback_plugin`.
|
||||||
|
fn handle_pause_solution_button(
|
||||||
|
interaction_query: Query<&Interaction, (Changed<Interaction>, With<PauseSolutionButton>)>,
|
||||||
|
mut pause: MessageWriter<PauseRequestEvent>,
|
||||||
|
mut solution: MessageWriter<crate::events::ShowSolutionRequestEvent>,
|
||||||
|
) {
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
pause.write(PauseRequestEvent);
|
||||||
|
solution.write(crate::events::ShowSolutionRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Translates a click on the pause modal's Forfeit button into a
|
/// Translates a click on the pause modal's Forfeit button into a
|
||||||
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
|
/// `ForfeitRequestEvent` so `handle_forfeit_request` can spawn the
|
||||||
/// confirm modal — same code path as the `G` accelerator.
|
/// confirm modal — same code path as the `G` accelerator.
|
||||||
@@ -498,6 +558,14 @@ fn spawn_pause_screen(
|
|||||||
ButtonVariant::Tertiary,
|
ButtonVariant::Tertiary,
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
PauseSolutionButton,
|
||||||
|
"Show solution",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Secondary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
PauseResumeButton,
|
PauseResumeButton,
|
||||||
@@ -969,7 +1037,10 @@ mod tests {
|
|||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
app.add_plugins(MinimalPlugins).add_plugins(PausePlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app.insert_resource(GameStateResource(GameState::new(1, DrawStockConfig::DrawOne)));
|
app.insert_resource(GameStateResource(GameState::new(
|
||||||
|
1,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
)));
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ use crate::resources::{GameStateResource, HintCycleIndex};
|
|||||||
/// emits the hint visuals once the task completes. At most one task
|
/// emits the hint visuals once the task completes. At most one task
|
||||||
/// is ever in flight: a fresh H press while a previous task is
|
/// is ever in flight: a fresh H press while a previous task is
|
||||||
/// running drops the previous task and queues the new one.
|
/// running drops the previous task and queues the new one.
|
||||||
|
/// Set wrapping the async hint pipeline (state-change drop → poll). The
|
||||||
|
/// HUD hint button orders `.before` it so a press cancels/spawns before
|
||||||
|
/// this frame's poll; the drag-highlight painters order `.after` it so
|
||||||
|
/// the hint sprite writes are settled (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PendingHint;
|
||||||
|
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
pub struct PendingHintTask {
|
pub struct PendingHintTask {
|
||||||
/// `Some` while the solver is still working on a verdict.
|
/// `Some` while the solver is still working on a verdict.
|
||||||
@@ -178,9 +185,9 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::events::HintVisualEvent;
|
use crate::events::HintVisualEvent;
|
||||||
use crate::input_plugin::HintSolverConfig;
|
use crate::input_plugin::HintSolverConfig;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::{Card, Deck, Rank, Suit};
|
use solitaire_core::{Card, Deck, Rank, Suit};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
/// Build a minimal Bevy app exercising only the polling system
|
/// Build a minimal Bevy app exercising only the polling system
|
||||||
/// and the resources/messages it touches.
|
/// and the resources/messages it touches.
|
||||||
@@ -220,32 +227,11 @@ mod tests {
|
|||||||
] {
|
] {
|
||||||
game.set_test_foundation_cards(foundation, Vec::new());
|
game.set_test_foundation_cards(foundation, Vec::new());
|
||||||
}
|
}
|
||||||
for tableau in [
|
for tableau in solitaire_core::TABLEAUS {
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
] {
|
|
||||||
game.set_test_tableau_cards(tableau, Vec::new());
|
game.set_test_tableau_cards(tableau, Vec::new());
|
||||||
}
|
}
|
||||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
let suits = Suit::SUITS;
|
||||||
let ranks_below_king = [
|
let ranks_below_king = &Rank::RANKS[..12]; // everything below King
|
||||||
Rank::Ace,
|
|
||||||
Rank::Two,
|
|
||||||
Rank::Three,
|
|
||||||
Rank::Four,
|
|
||||||
Rank::Five,
|
|
||||||
Rank::Six,
|
|
||||||
Rank::Seven,
|
|
||||||
Rank::Eight,
|
|
||||||
Rank::Nine,
|
|
||||||
Rank::Ten,
|
|
||||||
Rank::Jack,
|
|
||||||
Rank::Queen,
|
|
||||||
];
|
|
||||||
for (foundation, suit) in [
|
for (foundation, suit) in [
|
||||||
Foundation::Foundation1,
|
Foundation::Foundation1,
|
||||||
Foundation::Foundation2,
|
Foundation::Foundation2,
|
||||||
@@ -270,10 +256,7 @@ mod tests {
|
|||||||
.into_iter()
|
.into_iter()
|
||||||
.zip(suits.iter())
|
.zip(suits.iter())
|
||||||
{
|
{
|
||||||
game.set_test_tableau_cards(
|
game.set_test_tableau_cards(tableau, vec![Card::new(Deck::Deck1, *suit, Rank::King)]);
|
||||||
tableau,
|
|
||||||
vec![Card::new(Deck::Deck1, *suit, Rank::King)],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
game
|
game
|
||||||
}
|
}
|
||||||
@@ -288,9 +271,11 @@ mod tests {
|
|||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||||
.resource_mut::<PendingHintTask>()
|
near_finished_state(),
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
cfg.moves_budget,
|
||||||
|
cfg.states_budget,
|
||||||
|
);
|
||||||
|
|
||||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15);
|
||||||
while app.world().resource::<PendingHintTask>().is_pending() {
|
while app.world().resource::<PendingHintTask>().is_pending() {
|
||||||
@@ -327,9 +312,11 @@ mod tests {
|
|||||||
let mut app = pending_hint_app();
|
let mut app = pending_hint_app();
|
||||||
app.insert_resource(GameStateResource(near_finished_state()));
|
app.insert_resource(GameStateResource(near_finished_state()));
|
||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||||
.resource_mut::<PendingHintTask>()
|
near_finished_state(),
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
cfg.moves_budget,
|
||||||
|
cfg.states_budget,
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
app.world().resource::<PendingHintTask>().is_pending(),
|
app.world().resource::<PendingHintTask>().is_pending(),
|
||||||
"task is in flight after spawn",
|
"task is in flight after spawn",
|
||||||
@@ -365,18 +352,22 @@ mod tests {
|
|||||||
let cfg = *app.world().resource::<HintSolverConfig>();
|
let cfg = *app.world().resource::<HintSolverConfig>();
|
||||||
|
|
||||||
// First spawn.
|
// First spawn.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||||
.resource_mut::<PendingHintTask>()
|
near_finished_state(),
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
cfg.moves_budget,
|
||||||
|
cfg.states_budget,
|
||||||
|
);
|
||||||
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
let first_handle_present = app.world().resource::<PendingHintTask>().is_pending();
|
||||||
assert!(first_handle_present);
|
assert!(first_handle_present);
|
||||||
|
|
||||||
// Second spawn. The `spawn` helper drops the prior task
|
// Second spawn. The `spawn` helper drops the prior task
|
||||||
// before assigning the new one — at no point are two tasks
|
// before assigning the new one — at no point are two tasks
|
||||||
// in flight.
|
// in flight.
|
||||||
app.world_mut()
|
app.world_mut().resource_mut::<PendingHintTask>().spawn(
|
||||||
.resource_mut::<PendingHintTask>()
|
near_finished_state(),
|
||||||
.spawn(near_finished_state(), cfg.moves_budget, cfg.states_budget);
|
cfg.moves_budget,
|
||||||
|
cfg.states_budget,
|
||||||
|
);
|
||||||
// Resource still pending (the second task), but the first
|
// Resource still pending (the second task), but the first
|
||||||
// is gone. We can't directly observe the first handle once
|
// is gone. We can't directly observe the first handle once
|
||||||
// it's been overwritten — what we *can* assert is that the
|
// it's been overwritten — what we *can* assert is that the
|
||||||
|
|||||||
@@ -115,10 +115,27 @@ impl Plugin for PlayBySeedPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
handle_open_dialog,
|
handle_open_dialog,
|
||||||
handle_text_input,
|
// Writes the marker-filtered seed-dialog Text nodes —
|
||||||
tick_debounce_and_spawn_solver_task,
|
// disjoint from the other text painters (UiTextFx
|
||||||
poll_solver_task,
|
// club, #143).
|
||||||
handle_confirm,
|
handle_text_input
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
tick_debounce_and_spawn_solver_task
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
// Reads deal settings for the solver spawn; a frame
|
||||||
|
// of lag is invisible (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
poll_solver_task
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
// Shares the new-game request stream with the other
|
||||||
|
// writers (drained wholesale at GameMutation, #143).
|
||||||
|
handle_confirm
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
handle_cancel,
|
handle_cancel,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
|
|||||||
@@ -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,12 +55,18 @@ 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.
|
/// Self-ambiguous club for writers of
|
||||||
#[derive(Component, Debug)]
|
/// [`crate::events::ToggleProfileRequestEvent`] (HUD avatar button, home
|
||||||
pub struct ProfileCloseButton;
|
/// profile chip, HUD menu option). The You-hub reader drains the whole
|
||||||
|
/// queue, so writer append order is irrelevant; `you_hub_plugin` orders
|
||||||
|
/// its open/tab handling after the club (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ProfileToggleWriters;
|
||||||
|
|
||||||
/// Marker on the scrollable body Node inside the Profile modal.
|
/// Marker on the scrollable body Node inside the Profile modal.
|
||||||
///
|
///
|
||||||
@@ -87,13 +88,13 @@ 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>()
|
||||||
|
// Open/close/tab handling moved to `you_hub_plugin`
|
||||||
|
// (Phase E) — this plugin now owns body content + scroll.
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
scroll_profile_panel
|
||||||
toggle_profile_screen,
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
handle_profile_close_button,
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
scroll_profile_panel,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -124,70 +125,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 +151,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 +413,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 +555,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 +566,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
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ pub struct LevelUpEvent {
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct ProgressUpdate;
|
pub struct ProgressUpdate;
|
||||||
|
|
||||||
|
/// Set for `challenge_plugin::advance_on_challenge_win`. See the
|
||||||
|
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ChallengeCompletion;
|
||||||
|
|
||||||
|
/// Set for `daily_challenge_plugin::handle_daily_completion`. See the
|
||||||
|
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DailyCompletion;
|
||||||
|
|
||||||
|
/// Set for `weekly_goals_plugin::evaluate_weekly_goals`. See the
|
||||||
|
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct WeeklyGoalsEval;
|
||||||
|
|
||||||
/// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`,
|
/// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`,
|
||||||
/// and emits `LevelUpEvent` whenever a win crosses a level boundary.
|
/// and emits `LevelUpEvent` whenever a win crosses a level boundary.
|
||||||
///
|
///
|
||||||
@@ -74,7 +89,27 @@ impl Plugin for ProgressPlugin {
|
|||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
award_xp_on_win.after(GameMutation).in_set(ProgressUpdate),
|
award_xp_on_win
|
||||||
|
.after(GameMutation)
|
||||||
|
.in_set(ProgressUpdate)
|
||||||
|
// Base win XP lands before the mode bonuses so the
|
||||||
|
// level-up attribution is stable.
|
||||||
|
.before(ChallengeCompletion),
|
||||||
|
)
|
||||||
|
// Mode-completion evaluators, pinned in one explicit chain
|
||||||
|
// INSIDE ProgressUpdate — every `.after(ProgressUpdate)`
|
||||||
|
// reader then sees fully-settled progress. Each evaluator
|
||||||
|
// drains GameWonEvent via its own cursor and writes disjoint
|
||||||
|
// ProgressResource fields plus XP, but a daily win also counts
|
||||||
|
// toward weekly goals, so two members can fire on the same
|
||||||
|
// frame — we pin an order rather than argue the XP/level-up
|
||||||
|
// event stream is commutative (#143).
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
(ChallengeCompletion, DailyCompletion, WeeklyGoalsEval)
|
||||||
|
.chain()
|
||||||
|
.in_set(ProgressUpdate)
|
||||||
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,9 +47,10 @@ use bevy::input::touch::Touches;
|
|||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::window::PrimaryWindow;
|
use bevy::window::PrimaryWindow;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::{FOUNDATIONS, TABLEAUS};
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
use crate::card_plugin::TABLEAU_FACEDOWN_FAN_FRAC;
|
||||||
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
|
use crate::events::{MoveRejectedEvent, MoveRequestEvent};
|
||||||
@@ -184,16 +185,31 @@ impl Plugin for RadialMenuPlugin {
|
|||||||
// Tests inject `RadialCursorOverride` themselves; production
|
// Tests inject `RadialCursorOverride` themselves; production
|
||||||
// never touches it. We do not `init_resource` here so the
|
// never touches it. We do not `init_resource` here so the
|
||||||
// cursor-from-window path is the default.
|
// cursor-from-window path is the default.
|
||||||
|
// The radial gesture reacts to pointer state the drag pipeline
|
||||||
|
// settles, and its release handler feeds the shared move
|
||||||
|
// request stream consumed at GameMutation this frame, so the
|
||||||
|
// chain sits between PointerInput and GameMutation. The redraw
|
||||||
|
// reads settings (reduce-motion) where a frame of lag is
|
||||||
|
// invisible — SettingsAccess club (#143).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
radial_open_on_right_click,
|
radial_open_on_right_click,
|
||||||
radial_open_on_long_press,
|
radial_open_on_long_press,
|
||||||
radial_track_cursor,
|
radial_track_cursor,
|
||||||
radial_handle_release_or_cancel,
|
radial_handle_release_or_cancel
|
||||||
radial_redraw_overlay,
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
|
||||||
|
radial_redraw_overlay
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.after(crate::input_plugin::PointerInput)
|
||||||
|
// Selection keys also touch DragState (mutual pointer/
|
||||||
|
// keyboard exclusion) — keys settle first.
|
||||||
|
.after(crate::selection_plugin::SelectionKeySet)
|
||||||
|
.before(crate::game_plugin::GameMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,13 +270,13 @@ pub fn legal_destinations_for_card(
|
|||||||
game: &GameState,
|
game: &GameState,
|
||||||
) -> Vec<KlondikePile> {
|
) -> Vec<KlondikePile> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for foundation in foundations() {
|
for foundation in FOUNDATIONS {
|
||||||
let dest = KlondikePile::Foundation(foundation);
|
let dest = KlondikePile::Foundation(foundation);
|
||||||
if game.can_move_cards(source_pile, &dest, 1) {
|
if game.can_move_cards(source_pile, &dest, 1) {
|
||||||
out.push(dest);
|
out.push(dest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for tableau in tableaus() {
|
for tableau in TABLEAUS {
|
||||||
let dest = KlondikePile::Tableau(tableau);
|
let dest = KlondikePile::Tableau(tableau);
|
||||||
if game.can_move_cards(source_pile, &dest, 1) {
|
if game.can_move_cards(source_pile, &dest, 1) {
|
||||||
out.push(dest);
|
out.push(dest);
|
||||||
@@ -359,28 +375,6 @@ fn pile_cards(game: &GameState, pile: &KlondikePile) -> Vec<(Card, bool)> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const fn foundations() -> [Foundation; 4] {
|
|
||||||
[
|
|
||||||
Foundation::Foundation1,
|
|
||||||
Foundation::Foundation2,
|
|
||||||
Foundation::Foundation3,
|
|
||||||
Foundation::Foundation4,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const fn tableaus() -> [Tableau; 7] {
|
|
||||||
[
|
|
||||||
Tableau::Tableau1,
|
|
||||||
Tableau::Tableau2,
|
|
||||||
Tableau::Tableau3,
|
|
||||||
Tableau::Tableau4,
|
|
||||||
Tableau::Tableau5,
|
|
||||||
Tableau::Tableau6,
|
|
||||||
Tableau::Tableau7,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
/// Builds the `(destination, anchor)` list for a fresh radial open.
|
||||||
///
|
///
|
||||||
/// `half_extents` is the window half-size in world space — icons are clamped
|
/// `half_extents` is the window half-size in world space — icons are clamped
|
||||||
@@ -399,8 +393,10 @@ fn build_radial_destinations(
|
|||||||
.map(|(i, d)| {
|
.map(|(i, d)| {
|
||||||
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
let raw = radial_anchor_for_index(centre, count, i, RADIAL_RADIUS_PX);
|
||||||
let clamped = Vec2::new(
|
let clamped = Vec2::new(
|
||||||
raw.x.clamp(-half_extents.x + margin, half_extents.x - margin),
|
raw.x
|
||||||
raw.y.clamp(-half_extents.y + margin, half_extents.y - margin),
|
.clamp(-half_extents.x + margin, half_extents.x - margin),
|
||||||
|
raw.y
|
||||||
|
.clamp(-half_extents.y + margin, half_extents.y - margin),
|
||||||
);
|
);
|
||||||
(d, clamped)
|
(d, clamped)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -122,12 +122,8 @@ pub(crate) fn format_move_body(instruction: &KlondikeInstruction) -> String {
|
|||||||
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
/// `▌ MOVE LOG · COMPLETE` in `Completed`.
|
||||||
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
||||||
match state {
|
match state {
|
||||||
ReplayPlaybackState::Playing { replay, cursor, .. } => {
|
ReplayPlaybackState::Playing { moves, cursor, .. } => {
|
||||||
format!(
|
format!("\u{258C} MOVE LOG \u{00B7} {}/{}", cursor, moves.len())
|
||||||
"\u{258C} MOVE LOG \u{00B7} {}/{}",
|
|
||||||
cursor,
|
|
||||||
replay.moves.len()
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
ReplayPlaybackState::Completed => "\u{258C} MOVE LOG \u{00B7} COMPLETE".to_string(),
|
||||||
ReplayPlaybackState::Inactive => String::new(),
|
ReplayPlaybackState::Inactive => String::new(),
|
||||||
@@ -135,7 +131,7 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — formats the kth-most-recently-applied move's row
|
/// Pure helper — formats the kth-most-recently-applied move's row
|
||||||
/// text. `k = 1` is the active row (`replay.moves[cursor - 1]`,
|
/// text. `k = 1` is the active row (`moves[cursor - 1]`,
|
||||||
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
|
/// displayed as `"{cursor} │ {body}"`). `k = 2` is the row above
|
||||||
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
/// that (`moves[cursor - 2]` displayed as `"{cursor - 1} │ {body}"`),
|
||||||
/// and so on.
|
/// and so on.
|
||||||
@@ -147,14 +143,14 @@ pub(crate) fn format_move_log_header(state: &ReplayPlaybackState) -> String {
|
|||||||
/// for k=1 and k=2 only, k=3 returns empty).
|
/// for k=1 and k=2 only, k=3 returns empty).
|
||||||
/// - The move list is shorter than expected (defensive guard).
|
/// - The move list is shorter than expected (defensive guard).
|
||||||
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
if k == 0 || k > *cursor {
|
if k == 0 || k > *cursor {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
let zero_idx = *cursor - k;
|
let zero_idx = *cursor - k;
|
||||||
let Some(m) = replay.moves.get(zero_idx) else {
|
let Some(m) = moves.get(zero_idx) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let display_idx = *cursor - k + 1;
|
let display_idx = *cursor - k + 1;
|
||||||
@@ -162,7 +158,7 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
|
/// Pure helper — formats the kth-NEXT move's row text. `k = 1`
|
||||||
/// is the move that will apply next (`replay.moves[cursor]`,
|
/// is the move that will apply next (`moves[cursor]`,
|
||||||
/// displayed as `cursor + 1`); `k = 2` is the move after that,
|
/// displayed as `cursor + 1`); `k = 2` is the move after that,
|
||||||
/// and so on.
|
/// and so on.
|
||||||
///
|
///
|
||||||
@@ -174,14 +170,14 @@ pub(crate) fn format_kth_recent_row(state: &ReplayPlaybackState, k: usize) -> St
|
|||||||
/// replay — late in the move list, the trailing next rows
|
/// replay — late in the move list, the trailing next rows
|
||||||
/// stay empty).
|
/// stay empty).
|
||||||
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
pub(crate) fn format_kth_next_row(state: &ReplayPlaybackState, k: usize) -> String {
|
||||||
let ReplayPlaybackState::Playing { replay, cursor, .. } = state else {
|
let ReplayPlaybackState::Playing { moves, cursor, .. } = state else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
if k == 0 {
|
if k == 0 {
|
||||||
return String::new();
|
return String::new();
|
||||||
}
|
}
|
||||||
let zero_idx = *cursor + k - 1;
|
let zero_idx = *cursor + k - 1;
|
||||||
let Some(m) = replay.moves.get(zero_idx) else {
|
let Some(m) = moves.get(zero_idx) else {
|
||||||
return String::new();
|
return String::new();
|
||||||
};
|
};
|
||||||
let display_idx = *cursor + k;
|
let display_idx = *cursor + k;
|
||||||
@@ -246,7 +242,11 @@ pub(crate) fn format_suit_glyph(suit: Suit) -> &'static str {
|
|||||||
/// known card, or `"--"` for an absent top card (empty pile).
|
/// known card, or `"--"` for an absent top card (empty pile).
|
||||||
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
pub(crate) fn format_card_short(card: Option<&(Card, bool)>) -> String {
|
||||||
match card {
|
match card {
|
||||||
Some((c, _)) => format!("{}{}", format_rank_short(c.rank()), format_suit_glyph(c.suit())),
|
Some((c, _)) => format!(
|
||||||
|
"{}{}",
|
||||||
|
format_rank_short(c.rank()),
|
||||||
|
format_suit_glyph(c.suit())
|
||||||
|
),
|
||||||
None => "--".to_string(),
|
None => "--".to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -383,7 +383,7 @@ pub struct ReplayOverlayMoveLogPrevRow {
|
|||||||
/// Marker on a "next move" row below the active row. `offset`
|
/// Marker on a "next move" row below the active row. `offset`
|
||||||
/// is the 1-based distance forward from the active row:
|
/// is the 1-based distance forward from the active row:
|
||||||
/// `offset = 1` is the move that will apply next
|
/// `offset = 1` is the move that will apply next
|
||||||
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
|
/// (`moves[cursor]`, displayed as `cursor + 1`),
|
||||||
/// `offset = 2` is the one after that, and so on. Up to
|
/// `offset = 2` is the one after that, and so on. Up to
|
||||||
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
|
||||||
///
|
///
|
||||||
@@ -464,25 +464,71 @@ impl Plugin for ReplayOverlayPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
react_to_state_change,
|
react_to_state_change,
|
||||||
update_banner_label,
|
// Marker-filtered Text/Node repaints — each owns one
|
||||||
update_progress_text,
|
// disjoint overlay marker, so they join the UiTextFx
|
||||||
update_floating_progress_chip,
|
// club alongside the HUD text painters (#143).
|
||||||
update_scrub_fill,
|
update_banner_label
|
||||||
update_move_log_header,
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
update_move_log_active_row,
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
update_move_log_prev_rows,
|
update_progress_text
|
||||||
update_move_log_next_rows,
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
update_mini_tableau_foundations,
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
update_mini_tableau_stock_waste,
|
update_floating_progress_chip
|
||||||
update_pause_button_label,
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_scrub_fill
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_move_log_header
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_move_log_active_row
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_move_log_prev_rows
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_move_log_next_rows
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_mini_tableau_foundations
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_mini_tableau_stock_waste
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_pause_button_label
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_step_button,
|
// Step/scrub dispatch into the shared move/draw/undo
|
||||||
|
// request streams (drained wholesale at GameMutation),
|
||||||
|
// so they join the writer clubs (#143).
|
||||||
|
handle_step_button
|
||||||
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
handle_pause_keyboard,
|
handle_pause_keyboard,
|
||||||
handle_stop_keyboard,
|
handle_stop_keyboard,
|
||||||
handle_arrow_keyboard,
|
handle_arrow_keyboard
|
||||||
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
handle_stop_button,
|
handle_stop_button,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
// After the playback driver so painters render the
|
||||||
|
// post-tick state; before GameMutation so the step /
|
||||||
|
// scrub events are consumed this frame. Click handlers
|
||||||
|
// writing ReplayPlaybackState stay at the end of the
|
||||||
|
// chain — a press is observed by react_to_state_change
|
||||||
|
// next frame (state-driven loop, see above) (#143).
|
||||||
|
.after(crate::replay_playback::ReplayPlayback)
|
||||||
|
.before(crate::game_plugin::GameMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1258,11 +1304,11 @@ fn keybind_footer_hint_text() -> &'static str {
|
|||||||
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
|
||||||
/// position the marker outside the track.
|
/// position the marker outside the track.
|
||||||
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
|
||||||
let ReplayPlaybackState::Playing { replay, .. } = state else {
|
let ReplayPlaybackState::Playing { replay, moves, .. } = state else {
|
||||||
return None;
|
return None;
|
||||||
};
|
};
|
||||||
let idx = replay.win_move_index?;
|
let idx = replay.win_move_index?;
|
||||||
let total = replay.moves.len();
|
let total = moves.len();
|
||||||
if total == 0 {
|
if total == 0 {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
||||||
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, Tableau};
|
use solitaire_core::{Foundation, KlondikeInstruction, KlondikePile, SessionRecording, Tableau};
|
||||||
use solitaire_core::{Rank, Suit};
|
use solitaire_core::{Rank, Suit};
|
||||||
use solitaire_data::Replay;
|
use solitaire_data::Replay;
|
||||||
|
|
||||||
@@ -17,9 +17,11 @@ fn synthetic_replay(move_count: usize) -> Replay {
|
|||||||
120,
|
120,
|
||||||
1_000,
|
1_000,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 2).expect("valid date"),
|
||||||
(0..move_count)
|
SessionRecording::from_instructions_unchecked(
|
||||||
.map(|_| KlondikeInstruction::RotateStock)
|
42,
|
||||||
.collect(),
|
DrawStockConfig::DrawOne,
|
||||||
|
(0..move_count).map(|_| KlondikeInstruction::RotateStock),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,12 +95,7 @@ fn overlay_spawns_when_playback_starts() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -117,12 +114,7 @@ fn overlay_progress_text_reflects_cursor() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -138,12 +130,7 @@ fn overlay_stop_button_click_clears_playback() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(overlay_root_count(&mut app), 1);
|
assert_eq!(overlay_root_count(&mut app), 1);
|
||||||
@@ -195,12 +182,7 @@ fn floating_chip_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||||
replay: synthetic_replay(5),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -232,12 +214,7 @@ fn overlay_despawns_when_playback_returns_to_inactive() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(3), 1, 0.5, false),
|
||||||
replay: synthetic_replay(3),
|
|
||||||
cursor: 1,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(overlay_root_count(&mut app), 1);
|
assert_eq!(overlay_root_count(&mut app), 1);
|
||||||
@@ -260,12 +237,7 @@ fn overlay_text_changes_on_completed() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(7), 7, 0.0, false),
|
||||||
replay: synthetic_replay(7),
|
|
||||||
cursor: 7,
|
|
||||||
secs_to_next: 0.0,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(banner_text(&mut app), "\u{258C} replay");
|
assert_eq!(banner_text(&mut app), "\u{258C} replay");
|
||||||
@@ -314,30 +286,30 @@ fn scrub_pct_covers_state_corners() {
|
|||||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
|
assert_eq!(scrub_pct(&ReplayPlaybackState::Inactive), 0.0);
|
||||||
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
|
assert_eq!(scrub_pct(&ReplayPlaybackState::Completed), 100.0);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::Playing {
|
scrub_pct(&ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10),
|
synthetic_replay(10),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
}),
|
)),
|
||||||
0.0,
|
0.0,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::Playing {
|
scrub_pct(&ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10),
|
synthetic_replay(10),
|
||||||
cursor: 5,
|
5,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
}),
|
)),
|
||||||
50.0,
|
50.0,
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
scrub_pct(&ReplayPlaybackState::Playing {
|
scrub_pct(&ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10),
|
synthetic_replay(10),
|
||||||
cursor: 10,
|
10,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
}),
|
)),
|
||||||
100.0,
|
100.0,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -367,12 +339,12 @@ fn format_game_caption_covers_state_corners() {
|
|||||||
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
|
// Mar = 31, Apr = 30, May 2 = 122). Synthetic_replay always
|
||||||
// uses this date so the assertion is stable.
|
// uses this date so the assertion is stable.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_game_caption(&ReplayPlaybackState::Playing {
|
format_game_caption(&ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10),
|
synthetic_replay(10),
|
||||||
cursor: 5,
|
5,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
}),
|
)),
|
||||||
Some("GAME #2026-122".to_string()),
|
Some("GAME #2026-122".to_string()),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -381,12 +353,7 @@ fn format_game_caption_covers_state_corners() {
|
|||||||
let mut early_january = synthetic_replay(10);
|
let mut early_january = synthetic_replay(10);
|
||||||
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
|
early_january.recorded_at = NaiveDate::from_ymd_opt(2026, 1, 5).expect("valid date");
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_game_caption(&ReplayPlaybackState::Playing {
|
format_game_caption(&ReplayPlaybackState::playing(early_january, 0, 0.5, false,)),
|
||||||
replay: early_january,
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
}),
|
|
||||||
Some("GAME #2026-005".to_string()),
|
Some("GAME #2026-005".to_string()),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -399,12 +366,7 @@ fn overlay_game_caption_shows_replay_date() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
|
assert_eq!(game_caption_text(&mut app), "GAME #2026-122");
|
||||||
@@ -431,12 +393,7 @@ fn overlay_scrub_fill_tracks_cursor() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 2,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -447,12 +404,7 @@ fn overlay_scrub_fill_tracks_cursor() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 6, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 6,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -497,12 +449,7 @@ fn win_move_marker_pct_is_none_for_completed() {
|
|||||||
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
|
fn win_move_marker_pct_is_none_when_replay_lacks_field() {
|
||||||
// Synthetic replay constructor leaves win_move_index as None
|
// Synthetic replay constructor leaves win_move_index as None
|
||||||
// (legacy / pre-`ab857bb` path).
|
// (legacy / pre-`ab857bb` path).
|
||||||
let state = ReplayPlaybackState::Playing {
|
let state = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
assert_eq!(win_move_marker_pct(&state), None);
|
assert_eq!(win_move_marker_pct(&state), None);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -511,12 +458,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
|
|||||||
// 10 moves, win at index 9 → marker sits at 90 % of the track.
|
// 10 moves, win at index 9 → marker sits at 90 % of the track.
|
||||||
// Matches the recording semantic: cursor reaches the marker
|
// Matches the recording semantic: cursor reaches the marker
|
||||||
// exactly when the about-to-apply move IS the win move.
|
// exactly when the about-to-apply move IS the win move.
|
||||||
let state = ReplayPlaybackState::Playing {
|
let state = ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(10).with_win_move_index(Some(9)),
|
synthetic_replay(10).with_win_move_index(Some(9)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
};
|
);
|
||||||
assert_eq!(win_move_marker_pct(&state), Some(90.0));
|
assert_eq!(win_move_marker_pct(&state), Some(90.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -524,12 +471,12 @@ fn win_move_marker_pct_is_some_at_correct_position() {
|
|||||||
fn win_move_marker_pct_clamps_to_track_bounds() {
|
fn win_move_marker_pct_clamps_to_track_bounds() {
|
||||||
// Defensive: if a malformed replay carried `win_move_index >=
|
// Defensive: if a malformed replay carried `win_move_index >=
|
||||||
// total`, the marker must still sit on the track, not past it.
|
// total`, the marker must still sit on the track, not past it.
|
||||||
let state = ReplayPlaybackState::Playing {
|
let state = ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(5).with_win_move_index(Some(99)),
|
synthetic_replay(5).with_win_move_index(Some(99)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
};
|
);
|
||||||
assert_eq!(win_move_marker_pct(&state), Some(100.0));
|
assert_eq!(win_move_marker_pct(&state), Some(100.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -538,12 +485,12 @@ fn marker_spawned_when_replay_has_win_move_index() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -559,12 +506,7 @@ fn marker_not_spawned_when_replay_lacks_win_move_index() {
|
|||||||
// Default constructor → win_move_index: None (legacy replay).
|
// Default constructor → win_move_index: None (legacy replay).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -579,12 +521,12 @@ fn marker_despawns_when_replay_state_returns_to_inactive() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(win_marker_count(&mut app), 1);
|
assert_eq!(win_marker_count(&mut app), 1);
|
||||||
@@ -608,12 +550,12 @@ fn win_move_marker_carries_hc_background_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(
|
||||||
replay: synthetic_replay(8).with_win_move_index(Some(7)),
|
synthetic_replay(8).with_win_move_index(Some(7)),
|
||||||
cursor: 0,
|
0,
|
||||||
secs_to_next: 0.5,
|
0.5,
|
||||||
paused: false,
|
false,
|
||||||
},
|
),
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -669,12 +611,7 @@ fn scrub_notches_spawn_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -693,12 +630,7 @@ fn scrub_notches_carry_high_contrast_background_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -723,12 +655,7 @@ fn scrub_track_carries_high_contrast_background_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -759,12 +686,7 @@ fn scrub_notches_despawn_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(scrub_notch_count(&mut app), 5);
|
assert_eq!(scrub_notch_count(&mut app), 5);
|
||||||
@@ -821,12 +743,7 @@ fn scrub_notch_labels_spawn_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -844,12 +761,7 @@ fn scrub_notch_labels_carry_helper_strings() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -870,12 +782,7 @@ fn scrub_notch_labels_despawn_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(scrub_notch_label_count(&mut app), 5);
|
assert_eq!(scrub_notch_label_count(&mut app), 5);
|
||||||
@@ -947,12 +854,7 @@ fn keybind_footer_spawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -970,12 +872,7 @@ fn keybind_footer_paints_helper_strings() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -999,12 +896,7 @@ fn keybind_footer_carries_high_contrast_border_marker() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1025,12 +917,7 @@ fn keybind_footer_despawns_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(keybind_footer_count(&mut app), 1);
|
assert_eq!(keybind_footer_count(&mut app), 1);
|
||||||
@@ -1054,12 +941,7 @@ fn scrub_notches_spawn_even_without_win_marker() {
|
|||||||
// Default constructor → win_move_index: None.
|
// Default constructor → win_move_index: None.
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 0, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1144,12 +1026,7 @@ fn format_move_body_handles_stock_cycle() {
|
|||||||
/// `Inactive` → empty.
|
/// `Inactive` → empty.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_move_log_header_covers_state_branches() {
|
fn format_move_log_header_covers_state_branches() {
|
||||||
let playing = ReplayPlaybackState::Playing {
|
let playing = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_move_log_header(&playing),
|
format_move_log_header(&playing),
|
||||||
"\u{258C} MOVE LOG \u{00B7} 3/10"
|
"\u{258C} MOVE LOG \u{00B7} 3/10"
|
||||||
@@ -1167,24 +1044,14 @@ fn format_move_log_header_covers_state_branches() {
|
|||||||
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
|
/// `replay.moves[N - 1]` and the row reads `"N | ..."`.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_active_move_row_handles_cursor_zero_and_positive() {
|
fn format_active_move_row_handles_cursor_zero_and_positive() {
|
||||||
let cursor_zero = ReplayPlaybackState::Playing {
|
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_active_move_row(&cursor_zero),
|
format_active_move_row(&cursor_zero),
|
||||||
"",
|
"",
|
||||||
"cursor=0 means no move applied yet; row stays empty",
|
"cursor=0 means no move applied yet; row stays empty",
|
||||||
);
|
);
|
||||||
|
|
||||||
let cursor_three = ReplayPlaybackState::Playing {
|
let cursor_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
// synthetic_replay produces all StockClicks, so the body
|
// synthetic_replay produces all StockClicks, so the body
|
||||||
// is "stock cycle". The displayed index is 3 (cursor),
|
// is "stock cycle". The displayed index is 3 (cursor),
|
||||||
// matching the most-recently-applied move at moves[2].
|
// matching the most-recently-applied move at moves[2].
|
||||||
@@ -1208,12 +1075,7 @@ fn move_log_panel_spawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1232,12 +1094,7 @@ fn move_log_panel_header_paints_helper_string() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(8), 2, 0.5, false),
|
||||||
replay: synthetic_replay(8),
|
|
||||||
cursor: 2,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1254,12 +1111,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1271,12 +1123,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
|||||||
// Advance cursor to 2 (most-recently-applied move is moves[1]).
|
// Advance cursor to 2 (most-recently-applied move is moves[1]).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 2,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1292,12 +1139,7 @@ fn move_log_active_row_repaints_on_cursor_advance() {
|
|||||||
/// early in a replay don't paint stale text.
|
/// early in a replay don't paint stale text.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
|
fn format_kth_recent_row_handles_in_range_and_out_of_range() {
|
||||||
let state_at_three = ReplayPlaybackState::Playing {
|
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
// k=1 → active (most recent applied). cursor=3 → display=3.
|
// k=1 → active (most recent applied). cursor=3 → display=3.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_kth_recent_row(&state_at_three, 1),
|
format_kth_recent_row(&state_at_three, 1),
|
||||||
@@ -1348,12 +1190,7 @@ fn move_log_prev_rows_spawn_with_panel() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1371,12 +1208,7 @@ fn move_log_prev_rows_paint_helper_strings_at_spawn() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1401,12 +1233,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
|
|||||||
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
|
// Start at cursor=2: offset 1 → k=2 → display=1, offset 2 → k=3 → empty (k > cursor).
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 2, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 2,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1422,12 +1249,7 @@ fn move_log_prev_rows_repaint_on_cursor_advance() {
|
|||||||
// Advance to cursor=5 — both offsets now have history.
|
// Advance to cursor=5 — both offsets now have history.
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1465,12 +1287,7 @@ fn move_log_next_row_text_at_offset(app: &mut App, offset: u8) -> String {
|
|||||||
/// alongside in-range correctness.
|
/// alongside in-range correctness.
|
||||||
#[test]
|
#[test]
|
||||||
fn format_kth_next_row_handles_in_range_and_out_of_range() {
|
fn format_kth_next_row_handles_in_range_and_out_of_range() {
|
||||||
let state_at_three = ReplayPlaybackState::Playing {
|
let state_at_three = ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
// k=1 → moves[3], display=4
|
// k=1 → moves[3], display=4
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_kth_next_row(&state_at_three, 1),
|
format_kth_next_row(&state_at_three, 1),
|
||||||
@@ -1498,12 +1315,7 @@ fn move_log_next_rows_spawn_with_panel() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1520,12 +1332,7 @@ fn move_log_next_rows_paint_helper_strings_at_spawn() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1549,12 +1356,7 @@ fn move_log_next_rows_underfill_at_replay_end() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 9, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 9,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -1578,12 +1380,7 @@ fn active_row_wrapper_carries_accent_primary_background() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1621,12 +1418,7 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 3, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 3,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
@@ -1647,24 +1439,14 @@ fn active_row_text_uses_high_contrast_color_for_highlight() {
|
|||||||
/// dropping it has to also update this test.
|
/// dropping it has to also update this test.
|
||||||
#[test]
|
#[test]
|
||||||
fn active_row_format_includes_focus_prefix() {
|
fn active_row_format_includes_focus_prefix() {
|
||||||
let state = ReplayPlaybackState::Playing {
|
let state = ReplayPlaybackState::playing(synthetic_replay(10), 5, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 5,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
let row = format_active_move_row(&state);
|
let row = format_active_move_row(&state);
|
||||||
assert!(
|
assert!(
|
||||||
row.starts_with('\u{25B6}'),
|
row.starts_with('\u{25B6}'),
|
||||||
"active-row format must start with ▶ focus marker; got {row:?}",
|
"active-row format must start with ▶ focus marker; got {row:?}",
|
||||||
);
|
);
|
||||||
// Cursor=0 still returns empty, never just the prefix.
|
// Cursor=0 still returns empty, never just the prefix.
|
||||||
let cursor_zero = ReplayPlaybackState::Playing {
|
let cursor_zero = ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false);
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
};
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
format_active_move_row(&cursor_zero),
|
format_active_move_row(&cursor_zero),
|
||||||
"",
|
"",
|
||||||
@@ -1679,12 +1461,7 @@ fn move_log_panel_despawns_with_overlay() {
|
|||||||
let mut app = headless_app();
|
let mut app = headless_app();
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(10), 0, 0.5, false),
|
||||||
replay: synthetic_replay(10),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(move_log_panel_count(&mut app), 1);
|
assert_eq!(move_log_panel_count(&mut app), 1);
|
||||||
@@ -1728,21 +1505,11 @@ fn unique_button<M: Component>(app: &mut App) -> Entity {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
fn pressed_paused_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, true)
|
||||||
replay: synthetic_replay(replay_len),
|
|
||||||
cursor,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: true,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
fn running_state(replay_len: usize, cursor: usize) -> ReplayPlaybackState {
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(replay_len), cursor, 0.5, false)
|
||||||
replay: synthetic_replay(replay_len),
|
|
||||||
cursor,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2191,12 +1958,7 @@ fn dim_layer_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||||
replay: synthetic_replay(5),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -2255,12 +2017,7 @@ fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
|
|||||||
|
|
||||||
set_state(
|
set_state(
|
||||||
&mut app,
|
&mut app,
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::playing(synthetic_replay(5), 0, 0.5, false),
|
||||||
replay: synthetic_replay(5),
|
|
||||||
cursor: 0,
|
|
||||||
secs_to_next: 0.5,
|
|
||||||
paused: false,
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
app.update();
|
app.update();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
@@ -83,11 +83,11 @@ pub(crate) fn update_floating_progress_chip(
|
|||||||
// any). `cursor` is the index of the *next* move to apply, so
|
// any). `cursor` is the index of the *next* move to apply, so
|
||||||
// the most-recently-applied move sits at `cursor - 1`.
|
// the most-recently-applied move sits at `cursor - 1`.
|
||||||
let dest_pile = match state.as_ref() {
|
let dest_pile = match state.as_ref() {
|
||||||
ReplayPlaybackState::Playing { replay, cursor, .. } if *cursor > 0 => {
|
ReplayPlaybackState::Playing { moves, cursor, .. } if *cursor > 0 => {
|
||||||
// The destination pile is recoverable directly from the
|
// The destination pile is recoverable directly from the
|
||||||
// instruction — no live state needed. `RotateStock` has no
|
// instruction — no live state needed. `RotateStock` has no
|
||||||
// destination (the chip hides over the stock pile).
|
// destination (the chip hides over the stock pile).
|
||||||
match &replay.moves[cursor - 1] {
|
match &moves[cursor - 1] {
|
||||||
KlondikeInstruction::DstFoundation(dst) => {
|
KlondikeInstruction::DstFoundation(dst) => {
|
||||||
Some(KlondikePile::Foundation(dst.foundation))
|
Some(KlondikePile::Foundation(dst.foundation))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
//! In-engine replay playback core.
|
//! In-engine replay playback core.
|
||||||
//!
|
//!
|
||||||
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
//! When the player clicks "Watch replay" on the Stats overlay, the live
|
||||||
//! game state is reset to the deal seeded from the replay's `seed` /
|
//! game state is reset to the recorded deal — rebuilt directly from
|
||||||
//! `mode` / `draw_mode`, and the engine ticks through `replay.moves` at a
|
//! `replay.recording` via `GameState::from_recording`, never by
|
||||||
//! steady cadence — firing the canonical [`MoveRequestEvent`] /
|
//! re-dealing the seed — and the engine ticks through the recording's
|
||||||
//! [`DrawRequestEvent`] for each one. The existing animation pipeline
|
//! instruction list at a steady cadence, firing the canonical
|
||||||
//! plays back identically to a live game.
|
//! [`MoveRequestEvent`] / [`DrawRequestEvent`] for each one. The
|
||||||
|
//! existing animation pipeline plays back identically to a live game.
|
||||||
//!
|
//!
|
||||||
//! ## Public surface
|
//! ## Public surface
|
||||||
//!
|
//!
|
||||||
@@ -95,7 +96,7 @@ pub const REPLAY_COMPLETION_LINGER_SECS: f32 = 5.0;
|
|||||||
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
/// 3. The tick system [`tick_replay_playback`] advances `cursor` once
|
||||||
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
/// per [`REPLAY_MOVE_INTERVAL_SECS`] and fires the canonical event
|
||||||
/// for each [`KlondikeInstruction`].
|
/// for each [`KlondikeInstruction`].
|
||||||
/// 4. When `cursor == replay.moves.len()`, the state transitions to
|
/// 4. When `cursor == moves.len()`, the state transitions to
|
||||||
/// [`Completed`](Self::Completed). It lingers for
|
/// [`Completed`](Self::Completed). It lingers for
|
||||||
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
/// [`REPLAY_COMPLETION_LINGER_SECS`] (driven by
|
||||||
/// [`auto_clear_completed_replay`]) before returning to
|
/// [`auto_clear_completed_replay`]) before returning to
|
||||||
@@ -109,14 +110,20 @@ pub enum ReplayPlaybackState {
|
|||||||
#[default]
|
#[default]
|
||||||
Inactive,
|
Inactive,
|
||||||
/// A replay is currently being played back. The overlay reads
|
/// A replay is currently being played back. The overlay reads
|
||||||
/// `replay.moves.len()` for the denominator of the progress
|
/// `moves.len()` for the denominator of the progress
|
||||||
/// indicator and `cursor` for the numerator.
|
/// indicator and `cursor` for the numerator.
|
||||||
Playing {
|
Playing {
|
||||||
/// The replay being played back. Owned so the state is the
|
/// The replay being played back. Owned so the state is the
|
||||||
/// only place playback metadata lives — no separate resource
|
/// only place playback metadata lives — no separate resource
|
||||||
/// needed.
|
/// needed. Boxed: the v4 recording embeds the dealt board and
|
||||||
replay: Replay,
|
/// would otherwise dwarf the other variants
|
||||||
/// Index of the next move to apply, in `[0, replay.moves.len()]`.
|
/// (`clippy::large_enum_variant`).
|
||||||
|
replay: Box<Replay>,
|
||||||
|
/// Instruction list extracted once from `replay.recording` at
|
||||||
|
/// [`start_replay_playback`] time, so the per-frame tick indexes
|
||||||
|
/// a plain slice instead of re-walking the recording.
|
||||||
|
moves: Vec<KlondikeInstruction>,
|
||||||
|
/// Index of the next move to apply, in `[0, moves.len()]`.
|
||||||
cursor: usize,
|
cursor: usize,
|
||||||
/// Seconds remaining until the next move is dispatched.
|
/// Seconds remaining until the next move is dispatched.
|
||||||
secs_to_next: f32,
|
secs_to_next: f32,
|
||||||
@@ -138,6 +145,19 @@ pub enum ReplayPlaybackState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl ReplayPlaybackState {
|
impl ReplayPlaybackState {
|
||||||
|
/// Construct a [`Self::Playing`] state, extracting the instruction
|
||||||
|
/// list from `replay.recording` once up front.
|
||||||
|
pub fn playing(replay: Replay, cursor: usize, secs_to_next: f32, paused: bool) -> Self {
|
||||||
|
let moves = replay.recording.instructions();
|
||||||
|
Self::Playing {
|
||||||
|
replay: Box::new(replay),
|
||||||
|
moves,
|
||||||
|
cursor,
|
||||||
|
secs_to_next,
|
||||||
|
paused,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns `true` when a replay is currently being played back.
|
/// Returns `true` when a replay is currently being played back.
|
||||||
pub fn is_playing(&self) -> bool {
|
pub fn is_playing(&self) -> bool {
|
||||||
matches!(self, Self::Playing { .. })
|
matches!(self, Self::Playing { .. })
|
||||||
@@ -156,7 +176,7 @@ impl ReplayPlaybackState {
|
|||||||
/// the total is no longer available in `Completed`.
|
/// the total is no longer available in `Completed`.
|
||||||
pub fn progress(&self) -> Option<(usize, usize)> {
|
pub fn progress(&self) -> Option<(usize, usize)> {
|
||||||
match self {
|
match self {
|
||||||
Self::Playing { replay, cursor, .. } => Some((*cursor, replay.moves.len())),
|
Self::Playing { moves, cursor, .. } => Some((*cursor, moves.len())),
|
||||||
Self::Inactive | Self::Completed => None,
|
Self::Inactive | Self::Completed => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +211,11 @@ pub fn start_replay_playback(
|
|||||||
) {
|
) {
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
// The recording carries the dealt board and the session config
|
||||||
|
// (including draw mode), so the fresh game is bit-identical to the
|
||||||
|
// recorded deal regardless of how the current build maps seeds to
|
||||||
|
// deals — `replay.seed` is presentation metadata only.
|
||||||
|
let (fresh, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||||
commands.insert_resource(GameStateResource(fresh));
|
commands.insert_resource(GameStateResource(fresh));
|
||||||
|
|
||||||
// Initial `secs_to_next` uses the constant rather than reading
|
// Initial `secs_to_next` uses the constant rather than reading
|
||||||
@@ -201,7 +225,8 @@ pub fn start_replay_playback(
|
|||||||
// unusually short setting; subsequent ticks read the live setting
|
// unusually short setting; subsequent ticks read the live setting
|
||||||
// every frame via [`tick_replay_playback`].
|
// every frame via [`tick_replay_playback`].
|
||||||
**state = ReplayPlaybackState::Playing {
|
**state = ReplayPlaybackState::Playing {
|
||||||
replay,
|
replay: Box::new(replay),
|
||||||
|
moves,
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
secs_to_next: REPLAY_MOVE_INTERVAL_SECS,
|
||||||
paused: false,
|
paused: false,
|
||||||
@@ -256,7 +281,7 @@ pub fn step_replay_playback(
|
|||||||
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
draws_writer: &mut MessageWriter<DrawRequestEvent>,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let ReplayPlaybackState::Playing {
|
let ReplayPlaybackState::Playing {
|
||||||
replay,
|
moves,
|
||||||
cursor,
|
cursor,
|
||||||
paused: true,
|
paused: true,
|
||||||
..
|
..
|
||||||
@@ -264,10 +289,10 @@ pub fn step_replay_playback(
|
|||||||
else {
|
else {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= moves.len() {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
let instruction = replay.moves[*cursor];
|
let instruction = moves[*cursor];
|
||||||
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
dispatch_instruction(instruction, *cursor, game, moves_writer, draws_writer);
|
||||||
*cursor += 1;
|
*cursor += 1;
|
||||||
true
|
true
|
||||||
@@ -362,7 +387,7 @@ pub fn step_backwards_replay_playback(
|
|||||||
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
/// Drains `secs_to_next` by `time.delta_secs()`. When the countdown
|
||||||
/// expires, fires the canonical event for the move at `cursor`,
|
/// expires, fires the canonical event for the move at `cursor`,
|
||||||
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
/// increments `cursor`, and resets `secs_to_next`. When `cursor`
|
||||||
/// reaches `replay.moves.len()`, transitions to
|
/// reaches `moves.len()`, transitions to
|
||||||
/// [`ReplayPlaybackState::Completed`].
|
/// [`ReplayPlaybackState::Completed`].
|
||||||
///
|
///
|
||||||
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
/// The advance loop is a `while`, not an `if`, so coarse time steps
|
||||||
@@ -384,10 +409,11 @@ fn tick_replay_playback(
|
|||||||
let mut transition_to_completed = false;
|
let mut transition_to_completed = false;
|
||||||
|
|
||||||
if let ReplayPlaybackState::Playing {
|
if let ReplayPlaybackState::Playing {
|
||||||
replay,
|
moves,
|
||||||
cursor,
|
cursor,
|
||||||
secs_to_next,
|
secs_to_next,
|
||||||
paused,
|
paused,
|
||||||
|
..
|
||||||
} = state.as_mut()
|
} = state.as_mut()
|
||||||
{
|
{
|
||||||
// While paused, the cursor and the timer freeze together —
|
// While paused, the cursor and the timer freeze together —
|
||||||
@@ -397,8 +423,8 @@ fn tick_replay_playback(
|
|||||||
// path.
|
// path.
|
||||||
if !*paused {
|
if !*paused {
|
||||||
*secs_to_next -= dt;
|
*secs_to_next -= dt;
|
||||||
while *secs_to_next <= 0.0 && *cursor < replay.moves.len() {
|
while *secs_to_next <= 0.0 && *cursor < moves.len() {
|
||||||
let instruction = replay.moves[*cursor];
|
let instruction = moves[*cursor];
|
||||||
dispatch_instruction(
|
dispatch_instruction(
|
||||||
instruction,
|
instruction,
|
||||||
*cursor,
|
*cursor,
|
||||||
@@ -410,7 +436,7 @@ fn tick_replay_playback(
|
|||||||
*secs_to_next += interval;
|
*secs_to_next += interval;
|
||||||
}
|
}
|
||||||
|
|
||||||
if *cursor >= replay.moves.len() {
|
if *cursor >= moves.len() {
|
||||||
transition_to_completed = true;
|
transition_to_completed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -524,6 +550,14 @@ fn fire_state_changed_on_completion(
|
|||||||
*last_was_completed = now_completed;
|
*last_was_completed = now_completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set wrapping the replay playback driver chain (tick → auto-clear →
|
||||||
|
/// completion `StateChangedEvent`). The tick feeds `MoveRequestEvent` /
|
||||||
|
/// `DrawRequestEvent` into this frame's [`GameMutation`], so the set sits
|
||||||
|
/// on the pre-mutation spine; the overlay UI (`replay_overlay`) orders
|
||||||
|
/// itself after this set so its painters render the post-tick state (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ReplayPlayback;
|
||||||
|
|
||||||
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
|
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
|
||||||
/// playback ticks, completion linger, and the recording-pause guard.
|
/// playback ticks, completion linger, and the recording-pause guard.
|
||||||
///
|
///
|
||||||
@@ -536,18 +570,46 @@ impl Plugin for ReplayPlaybackPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<ReplayPlaybackState>()
|
app.init_resource::<ReplayPlaybackState>()
|
||||||
.add_message::<StateChangedEvent>()
|
.add_message::<StateChangedEvent>()
|
||||||
.add_systems(
|
.configure_sets(
|
||||||
Update,
|
Update,
|
||||||
(
|
ReplayPlayback
|
||||||
tick_replay_playback,
|
.after(crate::game_plugin::PreMutation)
|
||||||
auto_clear_completed_replay,
|
// Input first: a stop/pause press or a drag-emitted
|
||||||
fire_state_changed_on_completion,
|
// request this frame is observed before the tick fires
|
||||||
)
|
// the next scripted move (#143).
|
||||||
.chain(),
|
.after(crate::input_plugin::PointerInput)
|
||||||
|
.before(GameMutation),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
record_replay_skip_during_playback.after(GameMutation),
|
(
|
||||||
|
// The tick shares the move/draw request streams with the
|
||||||
|
// pointer/keyboard writers (drained wholesale at
|
||||||
|
// GameMutation) and reads playback speed from settings.
|
||||||
|
tick_replay_playback
|
||||||
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
auto_clear_completed_replay,
|
||||||
|
// Chained before GameMutation: explicit order against the
|
||||||
|
// mutation systems' StateChangedEvent writes; readers
|
||||||
|
// drain the whole queue after GameMutation.
|
||||||
|
fire_state_changed_on_completion,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(ReplayPlayback),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
record_replay_skip_during_playback
|
||||||
|
.after(GameMutation)
|
||||||
|
// After AutoComplete so the stats overlay's watch-replay
|
||||||
|
// handler (which resets ReplayPlaybackState) and the
|
||||||
|
// win-path recorder have both settled (#143).
|
||||||
|
.after(crate::auto_complete_plugin::AutoComplete),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -562,8 +624,7 @@ mod tests {
|
|||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
use bevy::time::TimeUpdateStrategy;
|
use bevy::time::TimeUpdateStrategy;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use solitaire_core::KlondikeInstruction;
|
use solitaire_core::{DrawStockConfig, SessionRecording, game_state::GameMode};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::GameMode};
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
/// Builds a headless `App` with `MinimalPlugins`, `GamePlugin`, and
|
||||||
@@ -612,11 +673,11 @@ mod tests {
|
|||||||
60,
|
60,
|
||||||
500,
|
500,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![
|
SessionRecording::from_instructions_unchecked(
|
||||||
KlondikeInstruction::RotateStock,
|
12345,
|
||||||
KlondikeInstruction::RotateStock,
|
DrawStockConfig::DrawOne,
|
||||||
KlondikeInstruction::RotateStock,
|
[KlondikeInstruction::RotateStock; 3],
|
||||||
],
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -673,11 +734,14 @@ mod tests {
|
|||||||
let state = app.world().resource::<ReplayPlaybackState>();
|
let state = app.world().resource::<ReplayPlaybackState>();
|
||||||
match state {
|
match state {
|
||||||
ReplayPlaybackState::Playing {
|
ReplayPlaybackState::Playing {
|
||||||
cursor, replay: r, ..
|
cursor,
|
||||||
|
replay: r,
|
||||||
|
moves,
|
||||||
|
..
|
||||||
} => {
|
} => {
|
||||||
assert_eq!(*cursor, 0);
|
assert_eq!(*cursor, 0);
|
||||||
assert_eq!(r.seed, replay.seed);
|
assert_eq!(r.seed, replay.seed);
|
||||||
assert_eq!(r.moves.len(), 3);
|
assert_eq!(moves.len(), 3);
|
||||||
}
|
}
|
||||||
other => panic!("expected Playing, got {other:?}"),
|
other => panic!("expected Playing, got {other:?}"),
|
||||||
}
|
}
|
||||||
@@ -778,7 +842,11 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![KlondikeInstruction::RotateStock],
|
SessionRecording::from_instructions_unchecked(
|
||||||
|
42,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
[KlondikeInstruction::RotateStock],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
start_playback(&mut app, one_move);
|
start_playback(&mut app, one_move);
|
||||||
app.update();
|
app.update();
|
||||||
@@ -887,7 +955,11 @@ mod tests {
|
|||||||
10,
|
10,
|
||||||
100,
|
100,
|
||||||
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
NaiveDate::from_ymd_opt(2026, 5, 5).expect("valid date"),
|
||||||
vec![KlondikeInstruction::RotateStock; 10],
|
SessionRecording::from_instructions_unchecked(
|
||||||
|
7,
|
||||||
|
DrawStockConfig::DrawOne,
|
||||||
|
[KlondikeInstruction::RotateStock; 10],
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ use std::sync::Arc;
|
|||||||
use bevy::math::Vec2;
|
use bevy::math::Vec2;
|
||||||
use bevy::prelude::Resource;
|
use bevy::prelude::Resource;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use solitaire_core::KlondikePile;
|
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
|
use solitaire_core::KlondikePile;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
|
||||||
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
/// Wraps the currently active `GameState`. Single source of truth for the in-progress game.
|
||||||
@@ -102,6 +102,25 @@ pub enum SyncStatus {
|
|||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
pub struct SyncStatusResource(pub SyncStatus);
|
pub struct SyncStatusResource(pub SyncStatus);
|
||||||
|
|
||||||
|
/// Outcome of the most recent successful sync merge (Phase M — sync
|
||||||
|
/// transparency). `None` until the first pull of the session resolves.
|
||||||
|
///
|
||||||
|
/// The Account tab renders the conflict details; the sync poller emits a
|
||||||
|
/// one-line toast summary when `conflicts` is non-empty.
|
||||||
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
|
pub struct SyncConflictLog(pub Option<SyncMergeSummary>);
|
||||||
|
|
||||||
|
/// Timestamp + per-field conflict list captured from
|
||||||
|
/// [`solitaire_sync::merge`] after a pull resolves.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SyncMergeSummary {
|
||||||
|
/// When the merge resolved.
|
||||||
|
pub at: DateTime<Utc>,
|
||||||
|
/// Fields that could not be merged deterministically (best-effort
|
||||||
|
/// resolution already applied — see `solitaire_sync::merge`).
|
||||||
|
pub conflicts: Vec<solitaire_sync::ConflictReport>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tracks which hint the player is currently cycling through.
|
/// Tracks which hint the player is currently cycling through.
|
||||||
///
|
///
|
||||||
/// Incremented on each H press so repeated presses reveal different moves.
|
/// Incremented on each H press so repeated presses reveal different moves.
|
||||||
|
|||||||
@@ -74,17 +74,37 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
apply_safe_area_anchors,
|
// All three write Node padding on disjoint chrome roots
|
||||||
apply_safe_area_bottom_anchors,
|
// (top anchors / bottom anchors / modal scrims); chained
|
||||||
apply_safe_area_to_modal_scrims,
|
// for a deterministic pass, and they join the UiTextFx
|
||||||
on_app_resumed,
|
// chrome club — anchored roots are never the label/
|
||||||
|
// tooltip/readout entities the other members touch,
|
||||||
|
// which Bevy can't prove from the queries (#143).
|
||||||
|
(
|
||||||
|
apply_safe_area_anchors,
|
||||||
|
apply_safe_area_bottom_anchors,
|
||||||
|
apply_safe_area_to_modal_scrims,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
// UiScale is written on the settings mutator spine.
|
||||||
|
.after(crate::settings_plugin::SettingsMutation),
|
||||||
|
// Emits a synthetic WindowResized on resume only; every
|
||||||
|
// reader drains the queue via cursor, and a resume
|
||||||
|
// already spans hundreds of ms, so whether the synthetic
|
||||||
|
// event lands this frame or next is invisible — same
|
||||||
|
// argument as handle_fullscreen (#143).
|
||||||
|
on_app_resumed.ambiguous_with_all(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
#[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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,9 +119,10 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
fn apply_safe_area_anchors(
|
fn apply_safe_area_anchors(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||||||
) {
|
) {
|
||||||
if !insets.is_changed() {
|
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
||||||
@@ -117,19 +138,31 @@ fn apply_safe_area_anchors(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let top_logical = raw_top.min(max_inset);
|
let top_logical = raw_top.min(max_inset);
|
||||||
|
// `bevy::ui::UiScale` (Phase K) multiplies every Val::Px at layout
|
||||||
|
// time. `base_top` is UI chrome and SHOULD scale with the rest of
|
||||||
|
// the interface, but the system-bar inset is physical reality —
|
||||||
|
// pre-divide it so the post-multiplication offset stays exact.
|
||||||
|
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||||
for (anchor, mut node) in &mut q {
|
for (anchor, mut node) in &mut q {
|
||||||
node.top = Val::Px(anchor.base_top + top_logical);
|
node.top = Val::Px(anchor.base_top + top_logical / ui);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The live UI scale, defensively clamped — a zero or negative scale
|
||||||
|
/// would flip or destroy the inset math.
|
||||||
|
fn effective_ui_scale(ui_scale: Option<&UiScale>) -> f32 {
|
||||||
|
ui_scale.map_or(1.0, |s| s.0.max(0.1))
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
||||||
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||||||
fn apply_safe_area_bottom_anchors(
|
fn apply_safe_area_bottom_anchors(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
||||||
) {
|
) {
|
||||||
if !insets.is_changed() {
|
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
@@ -142,8 +175,12 @@ fn apply_safe_area_bottom_anchors(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let bottom_logical = raw_bottom.min(max_inset);
|
let bottom_logical = raw_bottom.min(max_inset);
|
||||||
|
// See `apply_safe_area_anchors`: the physical inset is pre-divided
|
||||||
|
// by the UI scale so a 90% setting can't sink the action bar into
|
||||||
|
// the gesture zone (and 130% doesn't over-inset it).
|
||||||
|
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||||
for (anchor, mut node) in &mut q {
|
for (anchor, mut node) in &mut q {
|
||||||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
|
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,19 +198,23 @@ fn apply_safe_area_bottom_anchors(
|
|||||||
fn apply_safe_area_to_modal_scrims(
|
fn apply_safe_area_to_modal_scrims(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
||||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||||
) {
|
) {
|
||||||
let has_new = !new_scrims.is_empty();
|
let has_new = !new_scrims.is_empty();
|
||||||
if !insets.is_changed() && !has_new {
|
if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
||||||
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
||||||
// value can't push the modal card off the visible area entirely.
|
// value can't push the modal card off the visible area entirely.
|
||||||
let top_logical = (insets.top / scale).min(window_height * 0.25);
|
// Physical insets are pre-divided by the UI scale — see
|
||||||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
|
// `apply_safe_area_anchors`.
|
||||||
|
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||||
|
let top_logical = (insets.top / scale).min(window_height * 0.25) / ui;
|
||||||
|
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25) / ui;
|
||||||
for mut node in &mut scrims {
|
for mut node in &mut scrims {
|
||||||
// Set both edges so the scrim's content box equals the usable area
|
// Set both edges so the scrim's content box equals the usable area
|
||||||
// between the status bar and the gesture/navigation bar. With
|
// between the status bar and the gesture/navigation bar. With
|
||||||
@@ -191,10 +232,12 @@ fn apply_safe_area_to_modal_scrims(
|
|||||||
/// whatever `SafeAreaInsets` are current at that moment.
|
/// whatever `SafeAreaInsets` are current at that moment.
|
||||||
///
|
///
|
||||||
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||||||
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
|
/// and resets `SafeAreaPollTries` (the cached `SafeAreaInsets` keep their
|
||||||
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
|
/// last-known values), causing `refresh_insets` to re-poll JNI over the next
|
||||||
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
|
/// few frames. If the insets changed while backgrounded, `on_safe_area_changed`
|
||||||
/// synthetic `WindowResized` and the layout converges to the right position.
|
/// in `table_plugin` emits a second synthetic `WindowResized` and the layout
|
||||||
|
/// converges to the right position; if they didn't, nothing is rewritten and
|
||||||
|
/// the layout stays put.
|
||||||
///
|
///
|
||||||
/// On non-Android targets this handler still fires — it ensures that a resume
|
/// On non-Android targets this handler still fires — it ensures that a resume
|
||||||
/// event always refreshes the layout (e.g., after a minimise/restore on
|
/// event always refreshes the layout (e.g., after a minimise/restore on
|
||||||
@@ -223,6 +266,7 @@ fn on_app_resumed(
|
|||||||
mod android {
|
mod android {
|
||||||
use super::{AppLifecycle, SafeAreaInsets};
|
use super::{AppLifecycle, SafeAreaInsets};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::WindowResized;
|
||||||
|
|
||||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||||
@@ -232,9 +276,14 @@ mod android {
|
|||||||
pub(super) struct SafeAreaPollTries(pub u32);
|
pub(super) struct SafeAreaPollTries(pub u32);
|
||||||
|
|
||||||
/// Polls Android for safe-area insets until we get a non-zero
|
/// Polls Android for safe-area insets until we get a non-zero
|
||||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
/// reading, then settles until [`rearm_on_resumed`] re-arms it on the
|
||||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
/// next foreground resume — insets can change while backgrounded
|
||||||
/// is typically frame 1–3 of a fresh launch.
|
/// (rotation, fold/unfold, gesture ↔ 3-button nav). The poll counter
|
||||||
|
/// (not `insets.is_populated()`) gates the loop, so a re-armed cycle
|
||||||
|
/// re-queries JNI even though cached values are already populated.
|
||||||
|
/// `getRootWindowInsets()` returns `null` (or all-zero `Insets`)
|
||||||
|
/// until the decor view has been laid out, which is typically frame
|
||||||
|
/// 1–3 of a fresh launch.
|
||||||
pub(super) fn refresh_insets(
|
pub(super) fn refresh_insets(
|
||||||
mut insets: ResMut<SafeAreaInsets>,
|
mut insets: ResMut<SafeAreaInsets>,
|
||||||
mut poll: ResMut<SafeAreaPollTries>,
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
@@ -243,18 +292,25 @@ mod android {
|
|||||||
// devices that genuinely report zero insets.
|
// devices that genuinely report zero insets.
|
||||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||||
|
|
||||||
if poll.0 >= MAX_TRIES || insets.is_populated() {
|
if poll.0 >= MAX_TRIES {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
poll.0 += 1;
|
poll.0 += 1;
|
||||||
|
|
||||||
match query_insets() {
|
match query_insets() {
|
||||||
Ok(v) if v.is_populated() => {
|
Ok(v) if v.is_populated() => {
|
||||||
info!(
|
if *insets != v {
|
||||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
info!(
|
||||||
v.top, v.bottom, v.left, v.right, poll.0
|
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||||
);
|
v.top, v.bottom, v.left, v.right, poll.0
|
||||||
*insets = v;
|
);
|
||||||
|
*insets = v;
|
||||||
|
}
|
||||||
|
// Settled for this poll cycle; `rearm_on_resumed` re-arms on
|
||||||
|
// the next resume. Writing `insets` only on an actual change
|
||||||
|
// keeps resource change detection (and the relayout it
|
||||||
|
// triggers) quiet on resumes where nothing moved.
|
||||||
|
poll.0 = MAX_TRIES;
|
||||||
}
|
}
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
// Layout not ready yet; try again next frame.
|
// Layout not ready yet; try again next frame.
|
||||||
@@ -285,11 +341,114 @@ mod android {
|
|||||||
) {
|
) {
|
||||||
for event in lifecycle.read() {
|
for event in lifecycle.read() {
|
||||||
if matches!(event, AppLifecycle::WillResume) {
|
if matches!(event, AppLifecycle::WillResume) {
|
||||||
|
// Evidence line for #130: winit's Android backend has open
|
||||||
|
// TODOs around forwarding resume notifications, so whether
|
||||||
|
// this ever fires on a given device is an open question.
|
||||||
|
info!("safe_area: AppLifecycle::WillResume received; re-arming inset poll");
|
||||||
poll.0 = 0;
|
poll.0 = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Polls the decor view's size via JNI and forces a relayout when it
|
||||||
|
/// disagrees with Bevy's cached `Window` resolution (#130).
|
||||||
|
///
|
||||||
|
/// winit's Android backend does not forward content-rect changes that
|
||||||
|
/// happen while the app is backgrounded (fold/unfold on foldables), so
|
||||||
|
/// after a fold cycle Bevy can keep rendering and laying out for the
|
||||||
|
/// previous screen's dimensions. Unlike `refresh_insets` this poller
|
||||||
|
/// never settles: it cannot rely on `AppLifecycle::WillResume` to re-arm
|
||||||
|
/// it, because that event is itself delivered through the same unreliable
|
||||||
|
/// lifecycle plumbing. A JNI round-trip every `POLL_INTERVAL_FRAMES`
|
||||||
|
/// frames is cheap.
|
||||||
|
///
|
||||||
|
/// On a mismatch it:
|
||||||
|
/// 1. writes the real size into `window.resolution` so the renderer
|
||||||
|
/// reconfigures the surface and systems reading `window.width()` see
|
||||||
|
/// the truth,
|
||||||
|
/// 2. emits a synthetic `WindowResized` (logical pixels) so
|
||||||
|
/// `on_window_resized` in `table_plugin` recomputes the board layout,
|
||||||
|
/// 3. re-arms the inset poller, because a screen change almost always
|
||||||
|
/// moves the system bars too — covering the "re-poll never fires"
|
||||||
|
/// hole left open in #116,
|
||||||
|
/// 4. emits `StateChangedEvent` so `card_plugin`'s sync pipeline
|
||||||
|
/// re-renders every card sprite from scratch — belt-and-braces for
|
||||||
|
/// the transient tableau clip of #130, where geometry converged but
|
||||||
|
/// stale card visuals survived the relayout.
|
||||||
|
pub(super) fn refresh_surface_size(
|
||||||
|
mut frame: Local<u32>,
|
||||||
|
mut windows: Query<(Entity, &mut Window)>,
|
||||||
|
mut resize_events: MessageWriter<WindowResized>,
|
||||||
|
mut state_events: MessageWriter<crate::events::StateChangedEvent>,
|
||||||
|
mut poll: ResMut<SafeAreaPollTries>,
|
||||||
|
) {
|
||||||
|
const POLL_INTERVAL_FRAMES: u32 = 30; // ~0.5 s @ 60 fps
|
||||||
|
|
||||||
|
*frame += 1;
|
||||||
|
if !frame.is_multiple_of(POLL_INTERVAL_FRAMES) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some((entity, mut window)) = windows.iter_mut().next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (decor_w, decor_h) = match query_decor_size() {
|
||||||
|
Ok(size) => size,
|
||||||
|
Err(e) => {
|
||||||
|
// One-time note; the bridge simply isn't up yet during the
|
||||||
|
// first frames of a launch.
|
||||||
|
if *frame == POLL_INTERVAL_FRAMES {
|
||||||
|
warn!("safe_area: decor size query failed (will retry): {e}");
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if decor_w == 0 || decor_h == 0 {
|
||||||
|
return; // decor view not laid out yet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reads go through `Deref` and do not trip change detection; only
|
||||||
|
// mutate `window` once a mismatch is confirmed.
|
||||||
|
let cached_w = window.resolution.physical_width();
|
||||||
|
let cached_h = window.resolution.physical_height();
|
||||||
|
if decor_w == cached_w && decor_h == cached_h {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"safe_area: decor view is {decor_w}x{decor_h} but cached resolution is \
|
||||||
|
{cached_w}x{cached_h}; forcing relayout (fold/unfold missed by winit?)"
|
||||||
|
);
|
||||||
|
window.resolution.set_physical_resolution(decor_w, decor_h);
|
||||||
|
let scale = window.scale_factor();
|
||||||
|
resize_events.write(WindowResized {
|
||||||
|
window: entity,
|
||||||
|
width: decor_w as f32 / scale,
|
||||||
|
height: decor_h as f32 / scale,
|
||||||
|
});
|
||||||
|
state_events.write(crate::events::StateChangedEvent);
|
||||||
|
poll.0 = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Physical pixel size of the activity's decor view — the ground truth
|
||||||
|
/// for the surface we are actually being displayed on, independent of
|
||||||
|
/// whatever winit last told Bevy.
|
||||||
|
fn query_decor_size() -> Result<(u32, u32), String> {
|
||||||
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
android_jni::with_activity_env(|env, activity| {
|
||||||
|
let window = env
|
||||||
|
.call_method(activity, "getWindow", "()Landroid/view/Window;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let decor = env
|
||||||
|
.call_method(&window, "getDecorView", "()Landroid/view/View;", &[])?
|
||||||
|
.l()?;
|
||||||
|
let w = env.call_method(&decor, "getWidth", "()I", &[])?.i()?;
|
||||||
|
let h = env.call_method(&decor, "getHeight", "()I", &[])?.i()?;
|
||||||
|
Ok((w.max(0) as u32, h.max(0) as u32))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||||
use solitaire_data::android_jni;
|
use solitaire_data::android_jni;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
//! Schedule hygiene checks (issue #143).
|
||||||
|
//!
|
||||||
|
//! Bevy runs systems with conflicting data access in nondeterministic order
|
||||||
|
//! unless an ordering edge exists. Nothing enforced this historically, and
|
||||||
|
//! the headless gameplay cluster carries a large legacy backlog of such
|
||||||
|
//! pairs — most benign (event/resource writers that never observably race),
|
||||||
|
//! but unproven. Rather than a permanently red hard-error test or no test
|
||||||
|
//! at all, this is a RATCHET: the count may only go down. New conflicting
|
||||||
|
//! pairs fail CI immediately; the backlog can be triaged incrementally
|
||||||
|
//! (add `.before`/`.after` where order matters, `.ambiguous_with` where it
|
||||||
|
//! provably doesn't, then lower `AMBIGUITY_BASELINE`).
|
||||||
|
//!
|
||||||
|
//! To see the offending pair names, add the `bevy_debug` / debug feature to
|
||||||
|
//! the bevy dev-dependency (system names are stripped otherwise) and set
|
||||||
|
//! `ambiguity_detection: LogLevel::Error` manually.
|
||||||
|
//!
|
||||||
|
//! Scope note: `CoreGamePlugin` (the full composition) performs real
|
||||||
|
//! storage/platform I/O in `build` and cannot run in a unit test; the
|
||||||
|
//! cluster below is the same one the engine's headless behaviour tests use.
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::achievement_plugin::AchievementPlugin;
|
||||||
|
use crate::animation_plugin::AnimationPlugin;
|
||||||
|
use crate::auto_complete_plugin::AutoCompletePlugin;
|
||||||
|
use crate::card_animation::CardAnimationPlugin;
|
||||||
|
use crate::card_plugin::CardPlugin;
|
||||||
|
use crate::challenge_plugin::ChallengePlugin;
|
||||||
|
use crate::cursor_plugin::CursorPlugin;
|
||||||
|
use crate::daily_challenge_plugin::DailyChallengePlugin;
|
||||||
|
use crate::difficulty_plugin::DifficultyPlugin;
|
||||||
|
use crate::feedback_anim_plugin::FeedbackAnimPlugin;
|
||||||
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::help_plugin::HelpPlugin;
|
||||||
|
use crate::home_plugin::HomePlugin;
|
||||||
|
use crate::hud_plugin::HudPlugin;
|
||||||
|
use crate::input_plugin::InputPlugin;
|
||||||
|
use crate::leaderboard_plugin::LeaderboardPlugin;
|
||||||
|
use crate::onboarding_plugin::OnboardingPlugin;
|
||||||
|
use crate::pause_plugin::PausePlugin;
|
||||||
|
use crate::play_by_seed_plugin::PlayBySeedPlugin;
|
||||||
|
use crate::profile_plugin::ProfilePlugin;
|
||||||
|
use crate::progress_plugin::ProgressPlugin;
|
||||||
|
use crate::radial_menu::RadialMenuPlugin;
|
||||||
|
use crate::replay_overlay::ReplayOverlayPlugin;
|
||||||
|
use crate::replay_playback::ReplayPlaybackPlugin;
|
||||||
|
use crate::safe_area::SafeAreaInsetsPlugin;
|
||||||
|
use crate::selection_plugin::SelectionPlugin;
|
||||||
|
use crate::settings_plugin::SettingsPlugin;
|
||||||
|
use crate::splash_plugin::SplashPlugin;
|
||||||
|
use crate::stats_plugin::StatsPlugin;
|
||||||
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use crate::time_attack_plugin::TimeAttackPlugin;
|
||||||
|
use crate::touch_selection_plugin::TouchSelectionPlugin;
|
||||||
|
use crate::ui_focus::UiFocusPlugin;
|
||||||
|
use crate::ui_modal::UiModalPlugin;
|
||||||
|
use crate::ui_tooltip::UiTooltipPlugin;
|
||||||
|
use crate::weekly_goals_plugin::WeeklyGoalsPlugin;
|
||||||
|
use crate::win_summary_plugin::WinSummaryPlugin;
|
||||||
|
|
||||||
|
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
|
||||||
|
/// same day (#143, PRs #146–#149) — this is now a hard gate. If your
|
||||||
|
/// change trips this assertion you have added a pair of systems with
|
||||||
|
/// conflicting data access and no ordering edge: add `.before`/`.after`
|
||||||
|
/// where order matters, or `.ambiguous_with` the relevant domain set
|
||||||
|
/// (BoardVisuals, MarkerVisuals, UiTextFx, HudButtons, writer sets)
|
||||||
|
/// where it provably does not. Do not raise this constant.
|
||||||
|
const AMBIGUITY_BASELINE: usize = 0;
|
||||||
|
|
||||||
|
fn cluster_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(GamePlugin)
|
||||||
|
.add_plugins(TablePlugin)
|
||||||
|
.add_plugins(CardPlugin)
|
||||||
|
.add_plugins(HudPlugin)
|
||||||
|
.add_plugins(AutoCompletePlugin)
|
||||||
|
.add_plugins(UiModalPlugin)
|
||||||
|
.add_plugins(UiFocusPlugin)
|
||||||
|
.add_plugins(SettingsPlugin::headless())
|
||||||
|
// Batch: game-adjacent plugins (all already exercised headless
|
||||||
|
// by the engine behaviour tests).
|
||||||
|
.add_plugins(ProgressPlugin::headless())
|
||||||
|
.add_plugins(StatsPlugin::headless())
|
||||||
|
.add_plugins(AchievementPlugin::headless())
|
||||||
|
.add_plugins(SelectionPlugin)
|
||||||
|
.add_plugins(AnimationPlugin)
|
||||||
|
.add_plugins(CardAnimationPlugin)
|
||||||
|
.add_plugins(FeedbackAnimPlugin)
|
||||||
|
.add_plugins(PausePlugin)
|
||||||
|
// Batch: screen/overlay plugins.
|
||||||
|
.add_plugins(HomePlugin::headless())
|
||||||
|
.add_plugins(WinSummaryPlugin)
|
||||||
|
.add_plugins(SplashPlugin)
|
||||||
|
.add_plugins(HelpPlugin)
|
||||||
|
.add_plugins(OnboardingPlugin)
|
||||||
|
.add_plugins(ProfilePlugin)
|
||||||
|
.add_plugins(LeaderboardPlugin)
|
||||||
|
// Batch: mode/replay/input plugins.
|
||||||
|
.add_plugins(ChallengePlugin)
|
||||||
|
.add_plugins(DailyChallengePlugin)
|
||||||
|
.add_plugins(TimeAttackPlugin)
|
||||||
|
.add_plugins(WeeklyGoalsPlugin)
|
||||||
|
.add_plugins(PlayBySeedPlugin)
|
||||||
|
.add_plugins(DifficultyPlugin)
|
||||||
|
.add_plugins(ReplayPlaybackPlugin)
|
||||||
|
.add_plugins(ReplayOverlayPlugin)
|
||||||
|
.add_plugins(RadialMenuPlugin)
|
||||||
|
.add_plugins(InputPlugin)
|
||||||
|
.add_plugins(TouchSelectionPlugin)
|
||||||
|
.add_plugins(UiTooltipPlugin)
|
||||||
|
.add_plugins(CursorPlugin)
|
||||||
|
.add_plugins(SafeAreaInsetsPlugin);
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn update_schedule_ambiguities_do_not_grow() {
|
||||||
|
let mut app = cluster_app();
|
||||||
|
app.edit_schedule(Update, |s| {
|
||||||
|
s.set_build_settings(ScheduleBuildSettings {
|
||||||
|
ambiguity_detection: LogLevel::Error,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// With LogLevel::Error the first schedule build panics when any
|
||||||
|
// ambiguity exists, and the panic message begins with the pair
|
||||||
|
// count. Catch it and ratchet on that count. (Parsing a panic
|
||||||
|
// message is brittle by design — if a Bevy upgrade rewords it,
|
||||||
|
// this test fails loudly and the parse below needs one-line
|
||||||
|
// maintenance, which is preferable to losing the ratchet.)
|
||||||
|
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||||
|
app.update();
|
||||||
|
}));
|
||||||
|
|
||||||
|
let count = match result {
|
||||||
|
Ok(()) => 0,
|
||||||
|
Err(payload) => {
|
||||||
|
let msg = payload
|
||||||
|
.downcast_ref::<String>()
|
||||||
|
.cloned()
|
||||||
|
.or_else(|| payload.downcast_ref::<&str>().map(|s| (*s).to_string()))
|
||||||
|
.unwrap_or_default();
|
||||||
|
let parsed = msg
|
||||||
|
.split(" pairs of systems")
|
||||||
|
.next()
|
||||||
|
.and_then(|prefix| prefix.split_whitespace().last())
|
||||||
|
.and_then(|n| n.parse::<usize>().ok());
|
||||||
|
parsed.unwrap_or_else(|| {
|
||||||
|
panic!("ambiguity panic message no longer parseable (Bevy upgrade?): {msg}")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
count, AMBIGUITY_BASELINE,
|
||||||
|
"system-order ambiguities changed from the enforced baseline. \
|
||||||
|
Add .before/.after or .ambiguous_with at the new registration site \
|
||||||
|
(or, if the count legitimately dropped below a nonzero baseline, \
|
||||||
|
lower AMBIGUITY_BASELINE).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
use bevy::input::ButtonInput;
|
use bevy::input::ButtonInput;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
|
||||||
use solitaire_core::Card;
|
use solitaire_core::Card;
|
||||||
use solitaire_core::game_state::GameState;
|
use solitaire_core::game_state::GameState;
|
||||||
|
use solitaire_core::{Foundation, KlondikePile, Tableau};
|
||||||
|
|
||||||
use crate::card_plugin::CardEntityIndex;
|
use crate::card_plugin::CardEntityIndex;
|
||||||
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
use crate::events::{InfoToastEvent, MoveRequestEvent, StateChangedEvent};
|
||||||
@@ -134,6 +134,14 @@ impl KeyboardDragState {
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct SelectionKeySet;
|
pub struct SelectionKeySet;
|
||||||
|
|
||||||
|
/// Set wrapping the post-mutation selection maintenance chain (clearing the
|
||||||
|
/// selection on state change, painting the highlight). Runs between
|
||||||
|
/// [`crate::game_plugin::GameMutation`] and
|
||||||
|
/// [`crate::card_plugin::BoardVisuals`] so the painters read the settled
|
||||||
|
/// selection/drag state (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SelectionFx;
|
||||||
|
|
||||||
/// Marker component placed on the outline sprite used as the keyboard-selection
|
/// Marker component placed on the outline sprite used as the keyboard-selection
|
||||||
/// highlight.
|
/// highlight.
|
||||||
///
|
///
|
||||||
@@ -158,14 +166,35 @@ impl Plugin for SelectionPlugin {
|
|||||||
(
|
(
|
||||||
handle_selection_keys
|
handle_selection_keys
|
||||||
.in_set(SelectionKeySet)
|
.in_set(SelectionKeySet)
|
||||||
.before(GameMutation),
|
.before(GameMutation)
|
||||||
clear_selection_on_state_change.after(GameMutation),
|
// Enter-to-move feeds the shared move request
|
||||||
update_selection_highlight.after(GameMutation).run_if(
|
// stream (drained wholesale at GameMutation, #143).
|
||||||
resource_changed::<SelectionState>
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
.or(resource_changed::<KeyboardDragState>)
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
|
||||||
.or(resource_changed::<GameStateResource>),
|
(
|
||||||
),
|
clear_selection_on_state_change,
|
||||||
|
update_selection_highlight.run_if(
|
||||||
|
resource_changed::<SelectionState>
|
||||||
|
.or(resource_changed::<KeyboardDragState>)
|
||||||
|
.or(resource_changed::<GameStateResource>),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(SelectionFx),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
SelectionFx
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
|
)
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
// Selection reads GameStateResource (legal destinations) —
|
||||||
|
// run after the pre-mutation spine that ticks/mutates it
|
||||||
|
// (#143).
|
||||||
|
SelectionKeySet.after(crate::game_plugin::PreMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,8 +516,10 @@ fn handle_selection_keys(
|
|||||||
1
|
1
|
||||||
};
|
};
|
||||||
let start = source_cards.len().saturating_sub(count);
|
let start = source_cards.len().saturating_sub(count);
|
||||||
let lifted_cards: Vec<Card> =
|
let lifted_cards: Vec<Card> = source_cards[start..]
|
||||||
source_cards[start..].iter().map(|(c, _)| c.clone()).collect();
|
.iter()
|
||||||
|
.map(|(c, _)| c.clone())
|
||||||
|
.collect();
|
||||||
let Some((bottom, _)) = source_cards.get(start) else {
|
let Some((bottom, _)) = source_cards.get(start) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -597,10 +628,7 @@ fn face_up_run_len(cards: &[(Card, bool)]) -> usize {
|
|||||||
/// This is intentionally separated from [`best_destination`] so the Enter
|
/// This is intentionally separated from [`best_destination`] so the Enter
|
||||||
/// handler can attempt a foundation move first and fall through to a
|
/// handler can attempt a foundation move first and fall through to a
|
||||||
/// multi-card stack move rather than accepting a single-card tableau move.
|
/// multi-card stack move rather than accepting a single-card tableau move.
|
||||||
fn try_foundation_dest(
|
fn try_foundation_dest(card: &Card, game: &GameState) -> Option<KlondikePile> {
|
||||||
card: &Card,
|
|
||||||
game: &GameState,
|
|
||||||
) -> Option<KlondikePile> {
|
|
||||||
let source = game.pile_containing_card(card.clone())?;
|
let source = game.pile_containing_card(card.clone())?;
|
||||||
for foundation in [
|
for foundation in [
|
||||||
Foundation::Foundation1,
|
Foundation::Foundation1,
|
||||||
@@ -697,13 +725,7 @@ fn update_selection_highlight(
|
|||||||
if let Some(ref pile) = source_pile
|
if let Some(ref pile) = source_pile
|
||||||
&& let Some(card) = top_face_up_card(pile, &game.0)
|
&& let Some(card) = top_face_up_card(pile, &game.0)
|
||||||
{
|
{
|
||||||
spawn_highlight_on_card(
|
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, source_color);
|
||||||
&mut commands,
|
|
||||||
&card_index,
|
|
||||||
&card,
|
|
||||||
card_size,
|
|
||||||
source_color,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Destination highlight while lifted.
|
// Destination highlight while lifted.
|
||||||
@@ -714,13 +736,7 @@ fn update_selection_highlight(
|
|||||||
// in destination-pick mode and the focused index is observable
|
// in destination-pick mode and the focused index is observable
|
||||||
// via the resource.
|
// via the resource.
|
||||||
if let Some(card) = top_face_up_card(dest, &game.0) {
|
if let Some(card) = top_face_up_card(dest, &game.0) {
|
||||||
spawn_highlight_on_card(
|
spawn_highlight_on_card(&mut commands, &card_index, &card, card_size, dest_color);
|
||||||
&mut commands,
|
|
||||||
&card_index,
|
|
||||||
&card,
|
|
||||||
card_size,
|
|
||||||
dest_color,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1047,10 +1063,7 @@ mod tests {
|
|||||||
press_key(&mut app, KeyCode::Tab);
|
press_key(&mut app, KeyCode::Tab);
|
||||||
app.update();
|
app.update();
|
||||||
|
|
||||||
let selected = app
|
let selected = app.world().resource::<SelectionState>().selected_pile;
|
||||||
.world()
|
|
||||||
.resource::<SelectionState>()
|
|
||||||
.selected_pile;
|
|
||||||
// The cycle order starts at Waste, but Waste is empty so the next
|
// The cycle order starts at Waste, but Waste is empty so the next
|
||||||
// available pile (Tableau(0)) is selected.
|
// available pile (Tableau(0)) is selected.
|
||||||
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
assert_eq!(selected, Some(KlondikePile::Tableau(Tableau::Tableau1)));
|
||||||
@@ -1216,16 +1229,10 @@ mod tests {
|
|||||||
drag.active_touch_id = None;
|
drag.active_touch_id = None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let before = app
|
let before = app.world().resource::<SelectionState>().selected_pile;
|
||||||
.world()
|
|
||||||
.resource::<SelectionState>()
|
|
||||||
.selected_pile;
|
|
||||||
press_key(&mut app, KeyCode::Tab);
|
press_key(&mut app, KeyCode::Tab);
|
||||||
app.update();
|
app.update();
|
||||||
let after = app
|
let after = app.world().resource::<SelectionState>().selected_pile;
|
||||||
.world()
|
|
||||||
.resource::<SelectionState>()
|
|
||||||
.selected_pile;
|
|
||||||
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
before, after,
|
before, after,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,139 @@
|
|||||||
|
//! Phase M (sync transparency): local backup export/import.
|
||||||
|
//!
|
||||||
|
//! Runs in the `Last` schedule — the export snapshot is taken after the
|
||||||
|
//! whole frame settles, and the import's resource rewrites become visible
|
||||||
|
//! to every Update system on the next frame, keeping this rare bulk
|
||||||
|
//! operation off the annotated Update spine entirely.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use solitaire_data::{DataBundle, data_bundle_path, load_bundle_from, save_bundle_to};
|
||||||
|
|
||||||
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
|
use crate::events::{
|
||||||
|
DataExportRequestEvent, DataImportRequestEvent, InfoToastEvent, WarningToastEvent,
|
||||||
|
};
|
||||||
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
|
use crate::stats_plugin::{StatsResource, StatsStoragePath};
|
||||||
|
|
||||||
|
use super::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||||
|
|
||||||
|
/// Drains the export/import request streams and performs the file IO.
|
||||||
|
///
|
||||||
|
/// The bundle is small (a few KB of JSON), so the write happens inline
|
||||||
|
/// rather than on the async pool — one synchronous write per explicit
|
||||||
|
/// button press, never per frame.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_data_transfer(
|
||||||
|
mut exports: MessageReader<DataExportRequestEvent>,
|
||||||
|
mut imports: MessageReader<DataImportRequestEvent>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
settings_path: Res<SettingsStoragePath>,
|
||||||
|
stats: Option<ResMut<StatsResource>>,
|
||||||
|
stats_path: Option<Res<StatsStoragePath>>,
|
||||||
|
achievements: Option<ResMut<AchievementsResource>>,
|
||||||
|
achievements_path: Option<Res<AchievementsStoragePath>>,
|
||||||
|
progress: Option<ResMut<ProgressResource>>,
|
||||||
|
progress_path: Option<Res<ProgressStoragePath>>,
|
||||||
|
mut settings_changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
) {
|
||||||
|
let export_requested = exports.read().next().is_some();
|
||||||
|
let import_requested = imports.read().next().is_some();
|
||||||
|
if !export_requested && !import_requested {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The bundle lives next to settings.json (same app data dir in
|
||||||
|
// production); deriving it from the injected settings path keeps
|
||||||
|
// tests off the real platform directory.
|
||||||
|
let path = settings_path
|
||||||
|
.0
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.with_file_name(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME))
|
||||||
|
.or_else(data_bundle_path);
|
||||||
|
let Some(path) = path else {
|
||||||
|
warning_toast.write(WarningToastEvent(
|
||||||
|
"No data directory available on this platform".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An import in the same frame as an export would race on the file;
|
||||||
|
// real UI can't produce both, but be deterministic anyway: export
|
||||||
|
// first, then import reads what was just written (a no-op restore).
|
||||||
|
if export_requested {
|
||||||
|
let bundle = DataBundle {
|
||||||
|
schema_version: solitaire_data::DATA_BUNDLE_SCHEMA_VERSION,
|
||||||
|
exported_at: Utc::now(),
|
||||||
|
settings: settings.0.clone(),
|
||||||
|
stats: stats.as_ref().map(|s| s.0.clone()).unwrap_or_default(),
|
||||||
|
achievements: achievements
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.0.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
progress: progress.as_ref().map(|p| p.0.clone()).unwrap_or_default(),
|
||||||
|
};
|
||||||
|
match save_bundle_to(&path, &bundle) {
|
||||||
|
Ok(()) => {
|
||||||
|
info_toast.write(InfoToastEvent(format!(
|
||||||
|
"Data exported to {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("data export failed: {e}");
|
||||||
|
warning_toast.write(WarningToastEvent(format!("Export failed: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if import_requested {
|
||||||
|
match load_bundle_from(&path) {
|
||||||
|
Ok(bundle) => {
|
||||||
|
settings.0 = bundle.settings;
|
||||||
|
if let Some(target) = &settings_path.0
|
||||||
|
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("import: failed to persist settings: {e}");
|
||||||
|
}
|
||||||
|
// Settings appliers (theme, audio, layout) react to the
|
||||||
|
// change event exactly as if edited in the panel.
|
||||||
|
settings_changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
|
||||||
|
if let Some(mut s) = stats {
|
||||||
|
s.0 = bundle.stats;
|
||||||
|
if let Some(target) = stats_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = solitaire_data::save_stats_to(target, &s.0)
|
||||||
|
{
|
||||||
|
warn!("import: failed to persist stats: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mut a) = achievements {
|
||||||
|
a.0 = bundle.achievements;
|
||||||
|
if let Some(target) = achievements_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = solitaire_data::save_achievements_to(target, &a.0)
|
||||||
|
{
|
||||||
|
warn!("import: failed to persist achievements: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mut p) = progress {
|
||||||
|
p.0 = bundle.progress;
|
||||||
|
if let Some(target) = progress_path.as_ref().and_then(|pp| pp.0.as_ref())
|
||||||
|
&& let Err(e) = solitaire_data::save_progress_to(target, &p.0)
|
||||||
|
{
|
||||||
|
warn!("import: failed to persist progress: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info_toast.write(InfoToastEvent(format!(
|
||||||
|
"Backup from {} restored",
|
||||||
|
bundle.exported_at.format("%Y-%m-%d %H:%M UTC")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("data import failed: {e}");
|
||||||
|
warning_toast.write(WarningToastEvent(format!("Import failed: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
//! Input handling for the Settings panel: button presses, keyboard
|
||||||
|
//! accelerators, focus attachment, and scrolling.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
|
||||||
|
use bevy::ui::{ComputedNode, UiGlobalTransform};
|
||||||
|
use solitaire_core::DrawStockConfig;
|
||||||
|
use solitaire_data::{
|
||||||
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS,
|
||||||
|
settings::Theme,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::events::{
|
||||||
|
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||||
|
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
};
|
||||||
|
use crate::ui_focus::{FocusGroup, Focusable, FocusedButton};
|
||||||
|
use crate::ui_modal::{ModalButton, ModalScrim};
|
||||||
|
use crate::ui_theme::SPACE_2;
|
||||||
|
|
||||||
|
pub(super) fn handle_volume_keys(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let mut delta = 0.0_f32;
|
||||||
|
if keys.just_pressed(KeyCode::BracketLeft) {
|
||||||
|
delta -= SFX_STEP;
|
||||||
|
}
|
||||||
|
if keys.just_pressed(KeyCode::BracketRight) {
|
||||||
|
delta += SFX_STEP;
|
||||||
|
}
|
||||||
|
if delta == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(delta);
|
||||||
|
if (before - after).abs() < f32::EPSILON {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
toast.write(InfoToastEvent(format!(
|
||||||
|
"SFX volume: {}%",
|
||||||
|
(after * 100.0).round() as i32
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens or closes the Settings panel — `O` keyboard accelerator or
|
||||||
|
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. 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(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
mut requests: MessageReader<ToggleSettingsRequestEvent>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
|
||||||
|
) {
|
||||||
|
let button_clicked = requests.read().count() > 0;
|
||||||
|
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
|
||||||
|
screen.0 = !screen.0;
|
||||||
|
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
|
||||||
|
screen.0 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reacts to button presses inside the Settings panel.
|
||||||
|
#[allow(clippy::too_many_arguments, clippy::type_complexity)]
|
||||||
|
pub(super) fn handle_settings_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut sfx_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut music_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<MusicVolumeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut draw_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<DrawModeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut theme_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ThemeText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut anim_speed_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<AnimSpeedText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut color_blind_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ColorBlindText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut high_contrast_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<HighContrastText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<ReduceMotionText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
mut reduce_motion_text: Query<
|
||||||
|
&mut Text,
|
||||||
|
(
|
||||||
|
With<ReduceMotionText>,
|
||||||
|
Without<SfxVolumeText>,
|
||||||
|
Without<MusicVolumeText>,
|
||||||
|
Without<DrawModeText>,
|
||||||
|
Without<ThemeText>,
|
||||||
|
Without<AnimSpeedText>,
|
||||||
|
Without<ColorBlindText>,
|
||||||
|
Without<HighContrastText>,
|
||||||
|
),
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SfxDown => {
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(-SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::SfxUp => {
|
||||||
|
let before = settings.0.sfx_volume;
|
||||||
|
let after = settings.0.adjust_sfx_volume(SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = sfx_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::MusicDown => {
|
||||||
|
let before = settings.0.music_volume;
|
||||||
|
let after = settings.0.adjust_music_volume(-SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::MusicUp => {
|
||||||
|
let before = settings.0.music_volume;
|
||||||
|
let after = settings.0.adjust_music_volume(SFX_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = music_text.single_mut() {
|
||||||
|
**t = format!("{after:.2}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleDrawMode => {
|
||||||
|
settings.0.draw_mode = match settings.0.draw_mode {
|
||||||
|
DrawStockConfig::DrawOne => DrawStockConfig::DrawThree,
|
||||||
|
DrawStockConfig::DrawThree => DrawStockConfig::DrawOne,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = draw_text.single_mut() {
|
||||||
|
**t = draw_mode_label(&settings.0.draw_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::CycleAnimSpeed => {
|
||||||
|
settings.0.animation_speed = match settings.0.animation_speed {
|
||||||
|
AnimSpeed::Normal => AnimSpeed::Fast,
|
||||||
|
AnimSpeed::Fast => AnimSpeed::Instant,
|
||||||
|
AnimSpeed::Instant => AnimSpeed::Normal,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = anim_speed_text.single_mut() {
|
||||||
|
**t = anim_speed_label(&settings.0.animation_speed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayDown => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(-TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_tooltip_delay_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TooltipDelayUp => {
|
||||||
|
let before = settings.0.tooltip_delay_secs;
|
||||||
|
let after = settings.0.adjust_tooltip_delay(TOOLTIP_DELAY_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TimeBonusDown => {
|
||||||
|
let before = settings.0.time_bonus_multiplier;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_time_bonus_multiplier(-TIME_BONUS_MULTIPLIER_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_time_bonus_multiplier_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::TimeBonusUp => {
|
||||||
|
let before = settings.0.time_bonus_multiplier;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_time_bonus_multiplier(TIME_BONUS_MULTIPLIER_STEP);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(-REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_replay_move_interval_text` on the next
|
||||||
|
// frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => {
|
||||||
|
let before = settings.0.replay_move_interval_secs;
|
||||||
|
let after = settings
|
||||||
|
.0
|
||||||
|
.adjust_replay_move_interval(REPLAY_MOVE_INTERVAL_STEP_SECS);
|
||||||
|
if (before - after).abs() > f32::EPSILON {
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleTheme => {
|
||||||
|
settings.0.theme = match settings.0.theme {
|
||||||
|
Theme::Green => Theme::Blue,
|
||||||
|
Theme::Blue => Theme::Dark,
|
||||||
|
Theme::Dark => Theme::Green,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = theme_text.single_mut() {
|
||||||
|
**t = theme_label(&settings.0.theme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleColorBlind => {
|
||||||
|
settings.0.color_blind_mode = !settings.0.color_blind_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = color_blind_text.single_mut() {
|
||||||
|
**t = color_blind_label(settings.0.color_blind_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleHighContrast => {
|
||||||
|
settings.0.high_contrast_mode = !settings.0.high_contrast_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = high_contrast_text.single_mut() {
|
||||||
|
**t = on_off_label(settings.0.high_contrast_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleReduceMotion => {
|
||||||
|
settings.0.reduce_motion_mode = !settings.0.reduce_motion_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
if let Ok(mut t) = reduce_motion_text.single_mut() {
|
||||||
|
**t = on_off_label(settings.0.reduce_motion_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleReduceTransparency => {
|
||||||
|
settings.0.reduce_transparency_mode = !settings.0.reduce_transparency_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_reduce_transparency_text`; the
|
||||||
|
// glass chrome itself follows via `update_glass_surfaces`
|
||||||
|
// next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleTouchInputMode => {
|
||||||
|
use solitaire_data::settings::TouchInputMode;
|
||||||
|
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||||||
|
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
|
||||||
|
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
|
||||||
|
};
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::CycleUiScale => {
|
||||||
|
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_ui_scale_text`; the live
|
||||||
|
// `bevy::ui::UiScale` resource follows via
|
||||||
|
// `sync_ui_scale_resource` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleWinnableDealsOnly => {
|
||||||
|
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by `update_winnable_deals_only_text`
|
||||||
|
// on the next frame via `settings.is_changed()`.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleAnalytics => {
|
||||||
|
settings.0.analytics_enabled = !settings.0.analytics_enabled;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_analytics_enabled_text` next frame.
|
||||||
|
}
|
||||||
|
SettingsButton::ToggleSmartDefaultSize => {
|
||||||
|
settings.0.disable_smart_default_size = !settings.0.disable_smart_default_size;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// The Text node is refreshed by
|
||||||
|
// `update_smart_default_size_text` next frame. The
|
||||||
|
// sizer system is gated only at startup, so flipping
|
||||||
|
// this mid-session takes effect on the next launch —
|
||||||
|
// documented on the field in `solitaire_data::Settings`.
|
||||||
|
}
|
||||||
|
SettingsButton::SelectCardBack(idx) => {
|
||||||
|
settings.0.selected_card_back = *idx;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
SettingsButton::SelectBackground(idx) => {
|
||||||
|
settings.0.selected_background = *idx;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
SettingsButton::SelectTheme(theme_id) => {
|
||||||
|
if settings.0.selected_theme_id != *theme_id {
|
||||||
|
settings.0.selected_theme_id = theme_id.clone();
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::ScanThemes => {
|
||||||
|
// Handled by `handle_scan_themes`.
|
||||||
|
}
|
||||||
|
SettingsButton::SyncNow
|
||||||
|
| SettingsButton::ConnectSync
|
||||||
|
| SettingsButton::DisconnectSync
|
||||||
|
| SettingsButton::DeleteAccount => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::OpenThemeStore => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
|
SettingsButton::ExportData | SettingsButton::ImportData => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
|
SettingsButton::Done => {
|
||||||
|
screen.0 = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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,
|
||||||
|
/// Delete Account, and the Phase M Export/Import data pair. Split from
|
||||||
|
/// `handle_settings_buttons` to stay within Bevy's 16-parameter system
|
||||||
|
/// limit.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_sync_buttons(
|
||||||
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
|
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||||
|
mut export_data: MessageWriter<crate::events::DataExportRequestEvent>,
|
||||||
|
mut import_data: MessageWriter<crate::events::DataImportRequestEvent>,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
|
||||||
|
crate::events::ThemeStoreOpenRequestEvent,
|
||||||
|
>,
|
||||||
|
mut screen: ResMut<SettingsScreen>,
|
||||||
|
) {
|
||||||
|
for (interaction, button) in &interaction_query {
|
||||||
|
if *interaction != Interaction::Pressed {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match button {
|
||||||
|
SettingsButton::SyncNow => {
|
||||||
|
manual_sync.write(ManualSyncRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::ConnectSync => {
|
||||||
|
// Close settings before the sync-setup modal opens so the
|
||||||
|
// guard in open_sync_setup_modal doesn't block on our own scrim.
|
||||||
|
screen.0 = false;
|
||||||
|
configure_sync.write(SyncConfigureRequestEvent);
|
||||||
|
}
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::OpenThemeStore => {
|
||||||
|
// Same shape as ConnectSync: close settings first so the
|
||||||
|
// store modal's own-scrim guard doesn't block.
|
||||||
|
screen.0 = false;
|
||||||
|
open_theme_store.write(crate::events::ThemeStoreOpenRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DisconnectSync => {
|
||||||
|
logout_sync.write(SyncLogoutRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::DeleteAccount => {
|
||||||
|
delete_account.write(DeleteAccountRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::ExportData => {
|
||||||
|
export_data.write(crate::events::DataExportRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::ImportData => {
|
||||||
|
import_data.write(crate::events::DataImportRequestEvent);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Auto-attaches [`Focusable`] to every bespoke Settings button — icon
|
||||||
|
/// buttons (volume +/−, toggle, cycle), swatch buttons (card-back,
|
||||||
|
/// background pickers), and the "Sync Now" button. The "Done" button is
|
||||||
|
/// already tagged by `attach_focusable_to_modal_buttons` (it carries
|
||||||
|
/// [`ModalButton`]) and is filtered out here.
|
||||||
|
///
|
||||||
|
/// Walks ancestors via [`ChildOf`] to find the [`ModalScrim`] that owns
|
||||||
|
/// the panel so the new [`Focusable`]'s group is bound to that scrim —
|
||||||
|
/// same defensive shape as the Phase 1 / 2 attach systems.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn attach_focusable_to_settings_buttons(
|
||||||
|
mut commands: Commands,
|
||||||
|
new_buttons: Query<
|
||||||
|
(Entity, &SettingsButton),
|
||||||
|
(With<Button>, Without<Focusable>, Without<ModalButton>),
|
||||||
|
>,
|
||||||
|
parents: Query<&ChildOf>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
) {
|
||||||
|
for (button, settings_button) in &new_buttons {
|
||||||
|
let mut current = button;
|
||||||
|
let mut scrim_entity: Option<Entity> = None;
|
||||||
|
for _ in 0..32 {
|
||||||
|
if scrims.get(current).is_ok() {
|
||||||
|
scrim_entity = Some(current);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match parents.get(current) {
|
||||||
|
Ok(parent) => current = parent.parent(),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(scrim) = scrim_entity {
|
||||||
|
commands.entity(button).insert(Focusable {
|
||||||
|
group: FocusGroup::Modal(scrim),
|
||||||
|
order: settings_button.focus_order(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Vertical padding (logical px) added around the focused button when
|
||||||
|
/// scrolling it into view. Keeps the focus ring's halo visible above /
|
||||||
|
/// below the viewport edge.
|
||||||
|
const FOCUS_SCROLL_PADDING: f32 = SPACE_2;
|
||||||
|
|
||||||
|
/// When the focused entity sits outside the visible Settings scroll
|
||||||
|
/// viewport, adjust the viewport's [`ScrollPosition`] so the button is
|
||||||
|
/// fully visible. No-op when:
|
||||||
|
///
|
||||||
|
/// - `FocusedButton` is `None`
|
||||||
|
/// - the focused entity has no [`UiGlobalTransform`] / [`ComputedNode`]
|
||||||
|
/// (e.g. a freshly-spawned modal hasn't laid out yet)
|
||||||
|
/// - the focused entity is not a descendant of the
|
||||||
|
/// [`SettingsPanelScrollable`] container
|
||||||
|
///
|
||||||
|
/// The viewport's visible Y range is `[scroll_y, scroll_y +
|
||||||
|
/// viewport_height]` in physical pixels (matching `ComputedNode.size`).
|
||||||
|
/// The focused button's vertical extent is computed from its
|
||||||
|
/// `UiGlobalTransform.translation.y` (centre, physical) ± half its
|
||||||
|
/// `ComputedNode.size.y`. Because the scroll container's local
|
||||||
|
/// coordinates run [0, content_height] and the visible window is
|
||||||
|
/// [scroll_y, scroll_y + viewport], we convert the button's window-
|
||||||
|
/// space Y to container-local Y by subtracting the container's window-
|
||||||
|
/// space top and adding the current scroll offset.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
pub(super) fn scroll_focus_into_view(
|
||||||
|
focused: Res<FocusedButton>,
|
||||||
|
parents: Query<&ChildOf>,
|
||||||
|
nodes: Query<(&UiGlobalTransform, &ComputedNode)>,
|
||||||
|
mut containers: Query<
|
||||||
|
(&mut ScrollPosition, &UiGlobalTransform, &ComputedNode),
|
||||||
|
With<SettingsPanelScrollable>,
|
||||||
|
>,
|
||||||
|
) {
|
||||||
|
let Some(target) = focused.0 else { return };
|
||||||
|
// Gather button geometry.
|
||||||
|
let Ok((target_transform, target_node)) = nodes.get(target) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Walk ancestors looking for the scroll container. Bounded to keep
|
||||||
|
// a malformed hierarchy from hanging the system.
|
||||||
|
let mut current = target;
|
||||||
|
let mut container_entity: Option<Entity> = None;
|
||||||
|
for _ in 0..32 {
|
||||||
|
if containers.get(current).is_ok() {
|
||||||
|
container_entity = Some(current);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
match parents.get(current) {
|
||||||
|
Ok(parent) => current = parent.parent(),
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let Some(container) = container_entity else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let Ok((mut scroll, container_transform, container_node)) = containers.get_mut(container)
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Geometry is reported in physical pixels by `ComputedNode.size` and
|
||||||
|
// `UiGlobalTransform.translation`. `ScrollPosition` is in logical px,
|
||||||
|
// so convert via `inverse_scale_factor` before we write.
|
||||||
|
let inv = target_node.inverse_scale_factor;
|
||||||
|
let target_height = target_node.size().y;
|
||||||
|
let target_centre_y = target_transform.translation.y;
|
||||||
|
let target_top = target_centre_y - target_height * 0.5;
|
||||||
|
let target_bottom = target_centre_y + target_height * 0.5;
|
||||||
|
|
||||||
|
let container_height = container_node.size().y;
|
||||||
|
let container_top = container_transform.translation.y - container_height * 0.5;
|
||||||
|
|
||||||
|
// Convert button window-space Y to container-local Y. The container
|
||||||
|
// is currently scrolled by `scroll.0.y` *logical* pixels — multiply
|
||||||
|
// by physical-per-logical to compare with physical pixel extents.
|
||||||
|
let scroll_phys = scroll.0.y / inv.max(f32::EPSILON);
|
||||||
|
let viewport_top = container_top + scroll_phys;
|
||||||
|
let viewport_bottom = viewport_top + container_height;
|
||||||
|
|
||||||
|
// Layout may not have run yet (zero size on first frame) — no
|
||||||
|
// sensible scroll target until the container has dimensions.
|
||||||
|
if container_height <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pad_phys = FOCUS_SCROLL_PADDING / inv.max(f32::EPSILON);
|
||||||
|
if target_top < viewport_top {
|
||||||
|
// Button extends above the viewport — scroll up.
|
||||||
|
let new_top = target_top - pad_phys;
|
||||||
|
let delta = new_top - viewport_top;
|
||||||
|
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||||
|
} else if target_bottom > viewport_bottom {
|
||||||
|
// Button extends below the viewport — scroll down.
|
||||||
|
let new_bottom = target_bottom + pad_phys;
|
||||||
|
let delta = new_bottom - viewport_bottom;
|
||||||
|
scroll.0.y = ((scroll_phys + delta) * inv).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scrolls the settings panel inner card in response to mouse-wheel events.
|
||||||
|
///
|
||||||
|
/// `offset_y` increases downward (0 = top of content). Scrolling down (ev.y < 0)
|
||||||
|
/// adds to the offset; scrolling up subtracts. Clamped to >= 0 so it never
|
||||||
|
/// scrolls past the top.
|
||||||
|
pub(super) fn scroll_settings_panel(
|
||||||
|
mut scroll_evr: MessageReader<MouseWheel>,
|
||||||
|
screen: Res<SettingsScreen>,
|
||||||
|
mut scrollables: Query<&mut ScrollPosition, With<SettingsPanelScrollable>>,
|
||||||
|
) {
|
||||||
|
if !screen.0 {
|
||||||
|
scroll_evr.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let delta_y: f32 = scroll_evr
|
||||||
|
.read()
|
||||||
|
.map(|ev| match ev.unit {
|
||||||
|
MouseScrollUnit::Line => ev.y * 50.0,
|
||||||
|
MouseScrollUnit::Pixel => ev.y,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
if delta_y == 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut sp in scrollables.iter_mut() {
|
||||||
|
sp.0.y = (sp.0.y - delta_y).max(0.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Window geometry persistence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
@@ -0,0 +1,725 @@
|
|||||||
|
//! Persists `solitaire_data::Settings`, exposes hotkeys for live tuning,
|
||||||
|
//! and renders a Bevy UI Settings panel.
|
||||||
|
//!
|
||||||
|
//! Hotkeys (always active, no overlay required):
|
||||||
|
//! - `[` — decrease SFX volume by `SFX_STEP`
|
||||||
|
//! - `]` — increase SFX volume by `SFX_STEP`
|
||||||
|
//! - `O` — open / close the Settings panel
|
||||||
|
//!
|
||||||
|
//! On change, the plugin persists `settings.json` and fires
|
||||||
|
//! `SettingsChangedEvent` so dependents (e.g. `AudioPlugin`) can react.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use bevy::input::mouse::MouseWheel;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::window::{WindowMoved, WindowResized};
|
||||||
|
use solitaire_data::{
|
||||||
|
Settings, WindowGeometry, load_settings_from, save_settings_to, settings_file_path,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::events::{
|
||||||
|
DeleteAccountRequestEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||||
|
SyncLogoutRequestEvent, ToggleSettingsRequestEvent,
|
||||||
|
};
|
||||||
|
use crate::resources::SettingsScrollPos;
|
||||||
|
use crate::theme::ThemeThumbnailPair;
|
||||||
|
|
||||||
|
mod data_transfer;
|
||||||
|
mod input;
|
||||||
|
mod ui;
|
||||||
|
mod updates;
|
||||||
|
|
||||||
|
use input::*;
|
||||||
|
use ui::*;
|
||||||
|
use updates::*;
|
||||||
|
|
||||||
|
/// Side length of a swatch button in the card-back / background pickers.
|
||||||
|
/// Smaller than the smallest spacing rung so it stays a literal.
|
||||||
|
const SWATCH_PX: f32 = 40.0;
|
||||||
|
|
||||||
|
/// Side length of a small toggle / cycle button (e.g. the "⇄" affordances).
|
||||||
|
/// Sub-rung sizing — kept as a literal, see SWATCH_PX. 32 px meets the
|
||||||
|
/// minimum desktop hit-target threshold while staying smaller than `SWATCH_PX`.
|
||||||
|
const ICON_BUTTON_PX: f32 = 32.0;
|
||||||
|
|
||||||
|
/// Volume adjustment step applied by the `[` / `]` hotkeys.
|
||||||
|
pub const SFX_STEP: f32 = 0.1;
|
||||||
|
|
||||||
|
/// Bevy resource wrapping the current `Settings`.
|
||||||
|
#[derive(Resource, Debug, Clone)]
|
||||||
|
pub struct SettingsResource(pub Settings);
|
||||||
|
|
||||||
|
/// Persistence path for `SettingsResource`. `None` disables I/O (used in tests).
|
||||||
|
#[derive(Resource, Debug, Clone)]
|
||||||
|
pub struct SettingsStoragePath(pub Option<PathBuf>);
|
||||||
|
|
||||||
|
/// Whether the Settings panel is currently visible. Toggle with `O`.
|
||||||
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
|
pub struct SettingsScreen(pub bool);
|
||||||
|
|
||||||
|
/// Debounce window for persisting window-geometry changes, in seconds.
|
||||||
|
///
|
||||||
|
/// `WindowResized` and `WindowMoved` fire continuously during a resize/
|
||||||
|
/// move drag, so writing to disk on every event would thrash the file
|
||||||
|
/// system. Instead the geometry-watch system records the pending value
|
||||||
|
/// and waits this long after the *last* event before saving.
|
||||||
|
pub const WINDOW_GEOMETRY_DEBOUNCE_SECS: f32 = 0.5;
|
||||||
|
|
||||||
|
/// Tracks a pending window-geometry change so the saver can debounce
|
||||||
|
/// `WindowResized` / `WindowMoved` storms during a resize / move drag.
|
||||||
|
#[derive(Resource, Debug, Default, Clone, Copy)]
|
||||||
|
pub struct PendingWindowGeometry {
|
||||||
|
/// Most recent observed geometry. `None` when nothing is pending.
|
||||||
|
pub geometry: Option<WindowGeometry>,
|
||||||
|
/// `Time::elapsed_secs()` value at which `geometry` was last updated.
|
||||||
|
pub last_changed_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fired whenever settings change so consumers (audio, UI) can react.
|
||||||
|
#[derive(Message, Debug, Clone)]
|
||||||
|
pub struct SettingsChangedEvent(pub Settings);
|
||||||
|
|
||||||
|
/// System set for the systems that mutate [`SettingsResource`] every frame
|
||||||
|
/// (hotkeys and window-geometry persistence). Ordered before
|
||||||
|
/// [`crate::game_plugin::GameMutation`]; readers of settings should sit
|
||||||
|
/// after this set (directly, or transitively via `.after(GameMutation)`)
|
||||||
|
/// so they observe the current frame's settings deterministically (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SettingsMutation;
|
||||||
|
|
||||||
|
/// Self-ambiguous club for the screen/overlay systems that touch
|
||||||
|
/// [`SettingsResource`] (onboarding first-run flag, home difficulty/draw
|
||||||
|
/// chips, leaderboard opt-in and display name, pause draw toggle, splash
|
||||||
|
/// reduce-motion reads, …). Each accessor touches its own disjoint fields,
|
||||||
|
/// and the modal-stacking guard means at most one of these screens is
|
||||||
|
/// interactive at a time — same-frame access order is provably irrelevant,
|
||||||
|
/// so members declare `.in_set(SettingsAccess).ambiguous_with(SettingsAccess)`
|
||||||
|
/// (#143). [`SettingsMutation`] is nested inside the club (its members keep
|
||||||
|
/// their internal chain). Do NOT add members whose settings read must be
|
||||||
|
/// same-frame-exact; order those `.after(SettingsMutation)` instead.
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SettingsAccess;
|
||||||
|
|
||||||
|
/// Marker on the root Settings panel entity.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct SettingsPanel;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live SFX volume value.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SfxVolumeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live music volume value.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct MusicVolumeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current draw mode.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct DrawModeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current theme.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ThemeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live sync status.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SyncStatusText;
|
||||||
|
|
||||||
|
/// Marks the headline `Text` node of the last-merge summary in the
|
||||||
|
/// Account tab (Phase M — sync transparency). Tests query it to assert
|
||||||
|
/// the summary rendered.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub(super) struct SyncMergeSummaryText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the active card-back index.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct CardBackText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current animation speed.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct AnimSpeedText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the active background index.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct BackgroundText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current color-blind mode state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ColorBlindText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current high-contrast mode state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct HighContrastText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current reduce-motion mode state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ReduceMotionText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current reduce-transparency state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ReduceTransparencyText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current touch input mode state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct TouchInputModeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current UI scale percentage.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct UiScaleText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct TooltipDelayText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live time-bonus-multiplier value.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct TimeBonusMultiplierText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the live replay-playback per-move
|
||||||
|
/// interval value. The Gameplay-section row beside this label lets the
|
||||||
|
/// player tune `Settings::replay_move_interval_secs`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ReplayMoveIntervalText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current "Winnable deals only"
|
||||||
|
/// state ("ON" / "OFF") in the Gameplay section.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct WinnableDealsOnlyText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current "Smart window size"
|
||||||
|
/// state ("ON" / "OFF") in the Gameplay section. The flag is stored
|
||||||
|
/// negatively in `Settings::disable_smart_default_size`, so the
|
||||||
|
/// label inverts: "ON" = smart sizing enabled (the default).
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SmartDefaultSizeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current "Share usage data" (analytics)
|
||||||
|
/// state ("ON" / "OFF") in the Privacy section.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
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.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SettingsPanelScrollable;
|
||||||
|
|
||||||
|
/// Marks the scrollable inner card so its `ScrollPosition` can be read before despawn.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct SettingsScrollNode;
|
||||||
|
|
||||||
|
/// Snapshot row used by [`spawn_settings_panel`] to render the card-art
|
||||||
|
/// theme picker. Carries the `ThemeRegistry` entry's display fields plus
|
||||||
|
/// the (optional) thumbnail pair from [`ThemeThumbnailCache`]. A `None`
|
||||||
|
/// thumbnail means the picker should render a placeholder swatch — used
|
||||||
|
/// when the cache hasn't generated handles yet, or when a user theme
|
||||||
|
/// is missing one of the required preview SVGs.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct ThemePickerEntry {
|
||||||
|
/// Stable theme id (matches `ThemeMeta::id`).
|
||||||
|
id: String,
|
||||||
|
/// Player-facing label.
|
||||||
|
display_name: String,
|
||||||
|
/// Pre-generated picker preview pair, when ready. `None` collapses
|
||||||
|
/// the chip to its plain-text fallback.
|
||||||
|
thumbnails: Option<ThemeThumbnailPair>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tags interactive buttons inside the Settings panel.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
enum SettingsButton {
|
||||||
|
SfxDown,
|
||||||
|
SfxUp,
|
||||||
|
MusicDown,
|
||||||
|
MusicUp,
|
||||||
|
ToggleDrawMode,
|
||||||
|
CycleAnimSpeed,
|
||||||
|
/// Decrement the tooltip-hover dwell delay by one step.
|
||||||
|
TooltipDelayDown,
|
||||||
|
/// Increment the tooltip-hover dwell delay by one step.
|
||||||
|
TooltipDelayUp,
|
||||||
|
/// Decrement the cosmetic time-bonus multiplier by one step.
|
||||||
|
TimeBonusDown,
|
||||||
|
/// Increment the cosmetic time-bonus multiplier by one step.
|
||||||
|
TimeBonusUp,
|
||||||
|
/// Decrement the replay-playback per-move interval by one step
|
||||||
|
/// (i.e. speed playback up).
|
||||||
|
ReplayMoveIntervalDown,
|
||||||
|
/// Increment the replay-playback per-move interval by one step
|
||||||
|
/// (i.e. slow playback down).
|
||||||
|
ReplayMoveIntervalUp,
|
||||||
|
ToggleTheme,
|
||||||
|
ToggleColorBlind,
|
||||||
|
/// Toggle the [`Settings::high_contrast_mode`] flag — boosts
|
||||||
|
/// foreground / suit-red glyphs to higher-luminance variants per
|
||||||
|
/// `design-system.md` §Accessibility (#2).
|
||||||
|
ToggleHighContrast,
|
||||||
|
/// Toggle the [`Settings::reduce_motion_mode`] flag — suppresses
|
||||||
|
/// non-essential motion (card-slide animations become instant
|
||||||
|
/// snaps) per `design-system.md` §Accessibility (#3).
|
||||||
|
ToggleReduceMotion,
|
||||||
|
/// Toggle the [`Settings::reduce_transparency_mode`] flag — swaps
|
||||||
|
/// translucent glass chrome (the floating touch tab bar) for opaque
|
||||||
|
/// fills so table content never bleeds through UI text.
|
||||||
|
ToggleReduceTransparency,
|
||||||
|
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
|
||||||
|
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||||
|
/// a card/stack, second tap on a target pile moves it).
|
||||||
|
ToggleTouchInputMode,
|
||||||
|
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
|
||||||
|
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
|
||||||
|
/// stays window-fit (Phase K).
|
||||||
|
CycleUiScale,
|
||||||
|
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||||
|
/// random Classic-mode deals are filtered through
|
||||||
|
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||||
|
/// winnable (or the retry cap is hit). Off by default.
|
||||||
|
ToggleWinnableDealsOnly,
|
||||||
|
/// Toggle the inverse of [`Settings::disable_smart_default_size`].
|
||||||
|
/// When the visible label reads "ON", the launch-time window
|
||||||
|
/// sizer scales the window to ~70 % of the primary monitor on a
|
||||||
|
/// fresh install; "OFF" pins the literal 1280×800 baseline. The
|
||||||
|
/// flag only affects launches without saved geometry — the
|
||||||
|
/// player's last window size always wins.
|
||||||
|
ToggleSmartDefaultSize,
|
||||||
|
/// Toggle [`Settings::analytics_enabled`]. Only rendered when a
|
||||||
|
/// Matomo URL is configured.
|
||||||
|
ToggleAnalytics,
|
||||||
|
/// Scan `user_theme_dir()` for new `.zip` files and import each one.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
ScanThemes,
|
||||||
|
/// Open the in-game theme-store modal (closes Settings first).
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
OpenThemeStore,
|
||||||
|
SyncNow,
|
||||||
|
/// Open the sync-server Connect modal (shown when backend = Local).
|
||||||
|
ConnectSync,
|
||||||
|
/// Disconnect from the sync server (shown when backend = SolitaireServer).
|
||||||
|
DisconnectSync,
|
||||||
|
/// Open the account-deletion confirmation modal.
|
||||||
|
DeleteAccount,
|
||||||
|
/// Write every JSON save into one backup bundle (Phase M).
|
||||||
|
ExportData,
|
||||||
|
/// Restore every JSON save from the backup bundle (Phase M).
|
||||||
|
ImportData,
|
||||||
|
Done,
|
||||||
|
/// Select a specific card-back by index from the picker row.
|
||||||
|
SelectCardBack(usize),
|
||||||
|
/// Select a specific background by index from the picker row.
|
||||||
|
SelectBackground(usize),
|
||||||
|
/// Select a specific card-art theme by `meta.id` from the
|
||||||
|
/// `ThemeRegistry`. The string is owned so the click handler can
|
||||||
|
/// hand it directly to `Settings::selected_theme_id`.
|
||||||
|
SelectTheme(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsButton {
|
||||||
|
/// Tab-walk priority — lower numbers visited first. Visual reading
|
||||||
|
/// order is top-to-bottom by section, left-to-right inside each row.
|
||||||
|
/// Two buttons in the same picker row receive the same `order`;
|
||||||
|
/// `handle_focus_keys` then breaks ties by entity index, which
|
||||||
|
/// matches `Children` spawn order inside each row.
|
||||||
|
fn focus_order(&self) -> i32 {
|
||||||
|
match self {
|
||||||
|
// Audio section
|
||||||
|
SettingsButton::SfxDown => 10,
|
||||||
|
SettingsButton::SfxUp => 11,
|
||||||
|
SettingsButton::MusicDown => 20,
|
||||||
|
SettingsButton::MusicUp => 21,
|
||||||
|
// Gameplay section
|
||||||
|
SettingsButton::ToggleDrawMode => 30,
|
||||||
|
SettingsButton::ToggleWinnableDealsOnly => 35,
|
||||||
|
SettingsButton::CycleAnimSpeed => 40,
|
||||||
|
SettingsButton::TooltipDelayDown => 45,
|
||||||
|
SettingsButton::TooltipDelayUp => 46,
|
||||||
|
SettingsButton::TimeBonusDown => 47,
|
||||||
|
SettingsButton::TimeBonusUp => 48,
|
||||||
|
// Replay-speed slider — last Gameplay-section row, so it
|
||||||
|
// sits between TimeBonusUp (48) and the Cosmetic section.
|
||||||
|
SettingsButton::ReplayMoveIntervalDown => 49,
|
||||||
|
SettingsButton::ReplayMoveIntervalUp => 49,
|
||||||
|
// Smart-default-size toggle — sits at the end of Gameplay.
|
||||||
|
SettingsButton::ToggleSmartDefaultSize => 50,
|
||||||
|
// Privacy section — just before Sync.
|
||||||
|
SettingsButton::ToggleAnalytics => 89,
|
||||||
|
// Cosmetic section
|
||||||
|
SettingsButton::ToggleTheme => 55,
|
||||||
|
SettingsButton::ToggleColorBlind => 60,
|
||||||
|
// Accessibility-section toggles sit alongside Color-blind so
|
||||||
|
// tab-walk visits all three a11y flags in the same vertical
|
||||||
|
// run before continuing to the picker rows.
|
||||||
|
SettingsButton::ToggleHighContrast => 61,
|
||||||
|
SettingsButton::ToggleReduceMotion => 62,
|
||||||
|
SettingsButton::ToggleReduceTransparency => 63,
|
||||||
|
SettingsButton::ToggleTouchInputMode => 64,
|
||||||
|
SettingsButton::CycleUiScale => 65,
|
||||||
|
// Picker rows — every swatch in a row shares the row's
|
||||||
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
|
SettingsButton::SelectCardBack(_) => 70,
|
||||||
|
SettingsButton::SelectBackground(_) => 80,
|
||||||
|
SettingsButton::SelectTheme(_) => 85,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::ScanThemes => 86,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
SettingsButton::OpenThemeStore => 87,
|
||||||
|
// Sync section
|
||||||
|
SettingsButton::SyncNow => 90,
|
||||||
|
SettingsButton::ConnectSync => 91,
|
||||||
|
SettingsButton::DisconnectSync => 92,
|
||||||
|
SettingsButton::DeleteAccount => 93,
|
||||||
|
SettingsButton::ExportData => 94,
|
||||||
|
SettingsButton::ImportData => 95,
|
||||||
|
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||||
|
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||||
|
// value here is only a fallback for completeness.
|
||||||
|
SettingsButton::Done => 100,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin that owns the settings lifecycle.
|
||||||
|
pub struct SettingsPlugin {
|
||||||
|
/// Path to `settings.json`. `None` in headless/test mode.
|
||||||
|
pub storage_path: Option<PathBuf>,
|
||||||
|
/// When `false`, panel spawn/despawn systems are not registered.
|
||||||
|
/// Use [`SettingsPlugin::headless`] for tests running under `MinimalPlugins`.
|
||||||
|
pub ui_enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SettingsPlugin {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
storage_path: settings_file_path(),
|
||||||
|
ui_enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SettingsPlugin {
|
||||||
|
/// No persistence, no UI — safe to use under `MinimalPlugins` in tests.
|
||||||
|
pub fn headless() -> Self {
|
||||||
|
Self {
|
||||||
|
storage_path: None,
|
||||||
|
ui_enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Plugin for SettingsPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
let loaded = match &self.storage_path {
|
||||||
|
Some(path) => load_settings_from(path),
|
||||||
|
None => Settings::default(),
|
||||||
|
};
|
||||||
|
app.insert_resource(SettingsResource(loaded))
|
||||||
|
.insert_resource(SettingsStoragePath(self.storage_path.clone()))
|
||||||
|
.init_resource::<SettingsScreen>()
|
||||||
|
.init_resource::<SettingsScrollPos>()
|
||||||
|
.init_resource::<ActiveSettingsTab>()
|
||||||
|
.init_resource::<PendingWindowGeometry>()
|
||||||
|
.add_message::<SettingsChangedEvent>()
|
||||||
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
|
.add_message::<SyncLogoutRequestEvent>()
|
||||||
|
.add_message::<DeleteAccountRequestEvent>()
|
||||||
|
.add_message::<crate::events::DataExportRequestEvent>()
|
||||||
|
.add_message::<crate::events::DataImportRequestEvent>()
|
||||||
|
// Idempotent — the sync plugin also registers it; needed here
|
||||||
|
// so the transfer handler's failure toasts work headless.
|
||||||
|
.add_message::<crate::events::WarningToastEvent>()
|
||||||
|
// Bulk backup/restore lives in Last: the export snapshot is
|
||||||
|
// taken post-frame and an import's rewrites surface next
|
||||||
|
// frame — keeps the rare path off the annotated Update spine.
|
||||||
|
.add_systems(Last, data_transfer::handle_data_transfer)
|
||||||
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
|
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<MouseWheel>()
|
||||||
|
.add_message::<TouchInput>()
|
||||||
|
// `WindowResized` / `WindowMoved` are real Bevy window events
|
||||||
|
// and emitted by the windowing backend under `DefaultPlugins`,
|
||||||
|
// but we register them explicitly here so the geometry watcher
|
||||||
|
// also runs cleanly under `MinimalPlugins` (tests).
|
||||||
|
.add_message::<WindowResized>()
|
||||||
|
.add_message::<WindowMoved>()
|
||||||
|
// Settings changes land before game logic runs: the mutator
|
||||||
|
// chain (volume keys → geometry record → geometry persist) is a
|
||||||
|
// deterministic spine, and the whole set precedes GameMutation so
|
||||||
|
// every reader already ordered after GameMutation sees this
|
||||||
|
// frame's settings transitively (ambiguity burn-down, #143).
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
SettingsMutation
|
||||||
|
.after(crate::layout::LayoutSystem::UpdateOnResize)
|
||||||
|
.before(crate::game_plugin::GameMutation)
|
||||||
|
// Nested in the club: members keep their chain, but
|
||||||
|
// their pairs against other screen-plugin settings
|
||||||
|
// accessors are declared irrelevant (#143).
|
||||||
|
.in_set(SettingsAccess)
|
||||||
|
.ambiguous_with(SettingsAccess),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
handle_volume_keys,
|
||||||
|
record_window_geometry_changes,
|
||||||
|
persist_window_geometry_after_debounce,
|
||||||
|
// State sync, not UI — runs even under `headless()`
|
||||||
|
// so the live `bevy::ui::UiScale` always mirrors the
|
||||||
|
// setting; rides the mutator spine so it is ordered
|
||||||
|
// after every settings writer this frame.
|
||||||
|
sync_ui_scale_resource,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(SettingsMutation),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
toggle_settings_screen
|
||||||
|
.before(crate::ui_focus::FocusKeys)
|
||||||
|
.ambiguous_with(crate::hud_plugin::HudButtons),
|
||||||
|
scroll_settings_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if self.ui_enabled {
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
// 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_sync_buttons,
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
handle_scan_themes,
|
||||||
|
update_sync_status_text,
|
||||||
|
update_card_back_text,
|
||||||
|
update_background_text,
|
||||||
|
update_anim_speed_text,
|
||||||
|
update_color_blind_text,
|
||||||
|
update_high_contrast_text,
|
||||||
|
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
|
||||||
|
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
||||||
|
update_reduce_motion_text,
|
||||||
|
update_touch_input_mode_text,
|
||||||
|
update_ui_scale_text,
|
||||||
|
update_tooltip_delay_text,
|
||||||
|
update_time_bonus_multiplier_text,
|
||||||
|
update_replay_move_interval_text,
|
||||||
|
update_winnable_deals_only_text,
|
||||||
|
update_smart_default_size_text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
update_reduce_transparency_text,
|
||||||
|
// Guards internally on settings-change / newly added
|
||||||
|
// glass — `Added<GlassSurface>` can't be expressed as
|
||||||
|
// a resource run condition.
|
||||||
|
update_glass_surfaces,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
update_analytics_enabled_text,
|
||||||
|
attach_focusable_to_settings_buttons,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
app.add_systems(Update, scroll_focus_into_view);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Internal helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn persist(path: &SettingsStoragePath, settings: &Settings) {
|
||||||
|
let Some(target) = &path.0 else { return };
|
||||||
|
if let Err(e) = save_settings_to(target, settings) {
|
||||||
|
warn!("failed to save settings: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure helper: returns `true` when a pending geometry change has sat
|
||||||
|
/// quietly long enough to flush to disk.
|
||||||
|
///
|
||||||
|
/// Extracted so the debounce condition can be unit-tested without
|
||||||
|
/// spinning up a Bevy app.
|
||||||
|
fn should_persist_geometry(now_secs: f32, last_changed_secs: f32) -> bool {
|
||||||
|
(now_secs - last_changed_secs) >= WINDOW_GEOMETRY_DEBOUNCE_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the geometry implied by an event pair `(width, height, x, y)`,
|
||||||
|
/// using each component from `existing` when the corresponding event-derived
|
||||||
|
/// value is `None`. Returns `None` when neither side supplies width/height.
|
||||||
|
///
|
||||||
|
/// Pure helper so the merge logic can be unit-tested without an `App`.
|
||||||
|
fn merge_geometry(
|
||||||
|
existing: Option<WindowGeometry>,
|
||||||
|
new_size: Option<(u32, u32)>,
|
||||||
|
new_pos: Option<(i32, i32)>,
|
||||||
|
) -> Option<WindowGeometry> {
|
||||||
|
let (width, height) = new_size.or_else(|| existing.map(|g| (g.width, g.height)))?;
|
||||||
|
let (x, y) = new_pos
|
||||||
|
.or_else(|| existing.map(|g| (g.x, g.y)))
|
||||||
|
.unwrap_or((0, 0));
|
||||||
|
Some(WindowGeometry {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Systems
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Returns the next unlocked index after `current` in the sorted `unlocked` list.
|
||||||
|
/// Wraps around. Falls back to `unlocked[0]` if `current` is not found.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn cycle_unlocked(unlocked: &[usize], current: usize) -> usize {
|
||||||
|
if unlocked.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let pos = unlocked.iter().position(|&i| i == current).unwrap_or(0);
|
||||||
|
unlocked[(pos + 1) % unlocked.len()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records `WindowResized` and `WindowMoved` events into
|
||||||
|
/// [`PendingWindowGeometry`], coalescing every event arriving this frame
|
||||||
|
/// into the latest pending geometry.
|
||||||
|
///
|
||||||
|
/// The actual disk write is debounced — see
|
||||||
|
/// [`persist_window_geometry_after_debounce`] — so the file system isn't
|
||||||
|
/// hit on every pixel of a resize / move drag.
|
||||||
|
fn record_window_geometry_changes(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut resized: MessageReader<WindowResized>,
|
||||||
|
mut moved: MessageReader<WindowMoved>,
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut pending: ResMut<PendingWindowGeometry>,
|
||||||
|
) {
|
||||||
|
// Read .last() — only the final event matters for persistence; the
|
||||||
|
// intermediate sizes/positions are noise during a drag.
|
||||||
|
let new_size = resized.read().last().map(|ev| {
|
||||||
|
(
|
||||||
|
ev.width.round().max(0.0) as u32,
|
||||||
|
ev.height.round().max(0.0) as u32,
|
||||||
|
)
|
||||||
|
});
|
||||||
|
let new_pos = moved.read().last().map(|ev| (ev.position.x, ev.position.y));
|
||||||
|
|
||||||
|
if new_size.is_none() && new_pos.is_none() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fold the new components into the existing pending value (if any),
|
||||||
|
// otherwise into the persisted geometry from settings.
|
||||||
|
let baseline = pending.geometry.or(settings.0.window_geometry);
|
||||||
|
let Some(geometry) = merge_geometry(baseline, new_size, new_pos) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
pending.geometry = Some(geometry);
|
||||||
|
pending.last_changed_secs = time.elapsed_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// After [`WINDOW_GEOMETRY_DEBOUNCE_SECS`] of quiet (no `WindowResized` or
|
||||||
|
/// `WindowMoved` events arriving), commits the pending geometry to
|
||||||
|
/// `SettingsResource` and writes `settings.json`. Skips the write when the
|
||||||
|
/// pending value already matches the settings (e.g. a resize that was
|
||||||
|
/// reverted, or a synthetic event with no geometry change).
|
||||||
|
fn persist_window_geometry_after_debounce(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut pending: ResMut<PendingWindowGeometry>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
path: Res<SettingsStoragePath>,
|
||||||
|
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
) {
|
||||||
|
let Some(new_geom) = pending.geometry else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !should_persist_geometry(time.elapsed_secs(), pending.last_changed_secs) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always clear the pending slot regardless of whether we end up
|
||||||
|
// writing — otherwise an idempotent change would re-trigger this
|
||||||
|
// system every tick.
|
||||||
|
pending.geometry = None;
|
||||||
|
|
||||||
|
if settings.0.window_geometry == Some(new_geom) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
settings.0.window_geometry = Some(new_geom);
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// UI construction
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user