Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87aec5bdf2 | |||
| 6f5cebdb02 | |||
| 9c96e2fade | |||
| eb6c93fb55 | |||
| 4aafc0a53d | |||
| c8878d6e8b | |||
| 2e52f544f1 | |||
| 2301cc65d3 | |||
| 0ecc1a92fd | |||
| 132fea911c | |||
| 18d7937b51 | |||
| fa84152429 | |||
| ffed6b27e9 | |||
| 7fc98f8801 | |||
| a4dfb0c6db | |||
| 67271266e1 | |||
| aa7b0f6eed | |||
| 69c6e88188 | |||
| 1eb40433a9 | |||
| f8f1f26d64 | |||
| 3bb3ddb6f8 | |||
| d3d8094ebb | |||
| 04e99a8d24 | |||
| 980312c22c | |||
| 9623bdeede | |||
| 4df13695fc | |||
| df22338c8a | |||
| 7f450aab17 | |||
| d8f67dcad3 | |||
| ccb77f76b8 | |||
| da54faf8e2 | |||
| f3d01b5890 | |||
| faefca0445 | |||
| 24d83c9ae3 | |||
| 9d4234cded | |||
| e48f652454 | |||
| c24c7f6b61 | |||
| 686f57252c | |||
| 059af2ac28 | |||
| 858012d926 | |||
| f6be961419 | |||
| 8a145154db | |||
| e17667d034 | |||
| 005e29d1ab | |||
| 9d3cc94831 | |||
| a9285ccb41 | |||
| 648c3ed11d | |||
| 102506f799 | |||
| 9b00af29d9 | |||
| ea28121675 |
@@ -6,10 +6,6 @@ on:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
NDK_VERSION: 30.0.14904198
|
||||
BUILD_TOOLS_VERSION: "36.1.0"
|
||||
PLATFORM: android-34
|
||||
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
||||
GITEA_URL: https://git.aleshym.co
|
||||
REPO: funman300/Ferrous-Solitaire
|
||||
@@ -17,75 +13,56 @@ env:
|
||||
jobs:
|
||||
build-apk:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.aleshym.co/funman300/android-builder:latest
|
||||
credentials:
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Cache Android SDK
|
||||
uses: actions/cache@v4
|
||||
id: android-cache
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: android-sdk-${{ env.NDK_VERSION }}-${{ env.BUILD_TOOLS_VERSION }}
|
||||
|
||||
- name: Install Android SDK + NDK
|
||||
if: steps.android-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p $ANDROID_HOME/cmdline-tools
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||
-O /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d $ANDROID_HOME/cmdline-tools
|
||||
mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null || true
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
"ndk;$NDK_VERSION" \
|
||||
"build-tools;$BUILD_TOOLS_VERSION" \
|
||||
"platforms;$PLATFORM"
|
||||
|
||||
- name: Set up Rust (stable + aarch64-android)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-linux-android
|
||||
|
||||
- name: Cache Cargo
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
~/.cargo/bin/cargo-ndk
|
||||
target/aarch64-linux-android
|
||||
key: cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-android-
|
||||
/usr/local/cargo/registry/index
|
||||
/usr/local/cargo/registry/cache
|
||||
/usr/local/cargo/git/db
|
||||
key: cargo-registry-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-registry-android-
|
||||
|
||||
- name: Install cargo-ndk
|
||||
run: cargo-ndk --version 2>/dev/null || cargo install cargo-ndk --version 4.1.2 --locked
|
||||
- name: Cache sccache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /root/.cache/sccache
|
||||
key: sccache-android-aarch64-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: sccache-android-aarch64-
|
||||
|
||||
- name: Get tag name
|
||||
id: tag
|
||||
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Decode release keystore
|
||||
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
||||
|
||||
- name: Build release APK
|
||||
env:
|
||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/${{ env.NDK_VERSION }}
|
||||
PROFILE: release
|
||||
ABIS: arm64-v8a
|
||||
KEYSTORE: ./release.jks
|
||||
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
||||
KEY_ALIAS: release
|
||||
KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
||||
VERSION_NAME: ${{ steps.tag.outputs.name }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /root/.cache/sccache
|
||||
run: bash scripts/build_android_apk.sh
|
||||
|
||||
- name: Get tag name
|
||||
id: tag
|
||||
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
- name: sccache stats
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Create or get Gitea release
|
||||
id: release
|
||||
@@ -94,7 +71,6 @@ jobs:
|
||||
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
|
||||
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
|
||||
|
||||
# Re-use an existing release for this tag (e.g. created manually).
|
||||
ID=$(curl -sf -H "$AUTH" "$BASE/releases/tags/$TAG" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
|
||||
2>/dev/null || true)
|
||||
@@ -116,7 +92,18 @@ jobs:
|
||||
|
||||
- name: Upload APK to release
|
||||
run: |
|
||||
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
|
||||
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
|
||||
RELEASE_ID="${{ steps.release.outputs.id }}"
|
||||
|
||||
# Remove any existing APK assets so re-runs don't accumulate duplicates.
|
||||
curl -sf -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets" \
|
||||
| python3 -c "import sys,json; [print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
|
||||
| while read AID; do
|
||||
curl -sf -X DELETE -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets/$AID"
|
||||
done
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
|
||||
-H "$AUTH" \
|
||||
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
|
||||
"${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}/releases/${{ steps.release.outputs.id }}/assets"
|
||||
"$BASE/releases/$RELEASE_ID/assets"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Build Android Builder Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'docker/android-builder.Dockerfile'
|
||||
- '.gitea/workflows/builder-image.yml'
|
||||
|
||||
env:
|
||||
IMAGE: git.aleshym.co/funman300/android-builder
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.aleshym.co
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/android-builder.Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:latest
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Install kustomize
|
||||
run: |
|
||||
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz | tar xz
|
||||
sudo mv kustomize /usr/local/bin/kustomize
|
||||
|
||||
- name: Pin image tag in deploy manifests
|
||||
|
||||
@@ -6,6 +6,75 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.33.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Face-down cards render as tiny red squares (startup ordering bug)**. The
|
||||
`load_initial_theme` system fell back to `"dark"` when `SettingsResource` was
|
||||
not yet available at `Startup`, which happens on every fresh run before the
|
||||
settings file is read. The dark theme's near-black card back (#151515) renders
|
||||
as fully-off pixels on AMOLED screens, leaving only a 24×32 px red badge
|
||||
visible. Changed the fallback to `"classic"` so startup behaviour matches the
|
||||
`default_theme_id()` set in v0.31.0. Cascade-collapse and top-row legibility
|
||||
issues were visual consequences of the same invisible-card-back problem, not
|
||||
separate layout bugs.
|
||||
|
||||
## [0.32.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stock-count badge overlaps waste pile on Android** (Bug 1). The badge was
|
||||
centred 12 px inward from the stock pile's right edge, but its half-width of
|
||||
17 px pushed it 5 px past the edge. On Android (`H_GAP_DIVISOR = 32`) the
|
||||
inter-pile gap is only ~4 px, so the badge's top-right corner covered the
|
||||
left edge of the adjacent waste card at `Z_STOCK_BADGE = 30` (above the
|
||||
card's Z ≈ 1). Fixed by moving the inset to 20 px so the badge right edge
|
||||
sits 3 px inside the stock card on every device.
|
||||
- **Oversized grey header bar** (Bug 2). The top HUD band was a full-width
|
||||
`Node` with an opaque dark-grey `BackgroundColor` sized to `HUD_BAND_HEIGHT`
|
||||
(64 px desktop / 80 px Android). Typical gameplay only shows one tier of
|
||||
score text (~30 px), leaving a large empty grey block. Removed the
|
||||
`BackgroundColor` from the band entity; the green felt now shows through and
|
||||
only the score text and avatar button are visible in the header area.
|
||||
|
||||
## [0.31.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Face-down cards rendered as solid red squares on AMOLED phones** (Bug 1).
|
||||
The dark theme's card back (`back.svg`) uses a near-black background
|
||||
(`#151515`) which AMOLED screens render as fully-off pixels, leaving only a
|
||||
tiny `#a54242` red badge visible — exactly what was reported. Fixed by
|
||||
changing the fresh-install default theme from "dark" to "classic" (white
|
||||
background with navy diamond pattern, clearly readable on all display types).
|
||||
Also corrected stale asset paths in `load_card_images` (`cards/backs/back_N`
|
||||
→ `cards/backs/classic/back_N`, `cards/faces/XY` → `cards/faces/classic/XY`)
|
||||
so the PNG fallback loads correctly when the embedded theme hasn't arrived yet.
|
||||
|
||||
## [0.30.0] — 2026-05-16
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tableau card spacing tightened.** Face-up card fan reduced from 25% to 18%
|
||||
of card height; face-down from 20% to 14%. Cards on tableau piles sit closer
|
||||
together while still showing enough of each card to read the pile depth.
|
||||
|
||||
## [0.29.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **APK versionCode hardcoded to 1** (`AndroidManifest.xml`, `build_android_apk.sh`).
|
||||
Every release shipped with `versionCode="1"` / `versionName="1.0"`, so Android
|
||||
silently refused upgrades and Obtainium permanently showed a false update
|
||||
notification. The CI now derives the version code from the release tag
|
||||
(e.g. v0.29.0 → 2900) and stamps it into the APK via `aapt2 link
|
||||
--version-code / --version-name`.
|
||||
- **CI kustomize install flaky** (`.gitea/workflows/docker-build.yml`).
|
||||
The `curl | bash install_kustomize.sh` pattern hit GitHub API rate limits
|
||||
on the shared runner IP, causing a `tar: no such file` failure. Replaced
|
||||
with a direct pinned tarball download (kustomize v5.4.3).
|
||||
|
||||
## [0.28.0] — 2026-05-14
|
||||
|
||||
### Changed
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: d761a150
|
||||
newTag: eb6c93fb
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
FROM ubuntu:22.04
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive \
|
||||
ANDROID_HOME=/opt/android-sdk \
|
||||
NDK_VERSION=30.0.14904198 \
|
||||
BUILD_TOOLS_VERSION=36.1.0 \
|
||||
PLATFORM=android-34 \
|
||||
RUSTUP_HOME=/usr/local/rustup \
|
||||
CARGO_HOME=/usr/local/cargo
|
||||
|
||||
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${NDK_VERSION} \
|
||||
PATH=/usr/local/cargo/bin:$PATH
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
openjdk-17-jdk-headless \
|
||||
build-essential wget unzip curl ca-certificates git zip python3 \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Node.js 20 — required by Gitea Actions composite actions (checkout, cache, etc.)
|
||||
RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \
|
||||
&& apt-get install -y --no-install-recommends nodejs \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Android SDK command-line tools
|
||||
RUN mkdir -p "$ANDROID_HOME/cmdline-tools" \
|
||||
&& wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||
-O /tmp/cmdtools.zip \
|
||||
&& unzip -q /tmp/cmdtools.zip -d "$ANDROID_HOME/cmdline-tools" \
|
||||
&& mv "$ANDROID_HOME/cmdline-tools/cmdline-tools" "$ANDROID_HOME/cmdline-tools/latest" \
|
||||
&& rm /tmp/cmdtools.zip \
|
||||
&& yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses >/dev/null 2>&1 || true \
|
||||
&& "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
|
||||
"ndk;${NDK_VERSION}" \
|
||||
"build-tools;${BUILD_TOOLS_VERSION}" \
|
||||
"platforms;${PLATFORM}"
|
||||
|
||||
# Rust stable + aarch64-linux-android target
|
||||
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --no-modify-path --default-toolchain stable \
|
||||
&& rustup target add aarch64-linux-android
|
||||
|
||||
# cargo-ndk (compiled once into the image)
|
||||
RUN cargo install cargo-ndk --version 4.1.2 --locked \
|
||||
&& rm -rf "$CARGO_HOME/registry" "$CARGO_HOME/git"
|
||||
|
||||
# sccache — pre-built musl binary, no Rust compile needed
|
||||
RUN curl -sL "https://github.com/mozilla/sccache/releases/download/v0.8.1/sccache-v0.8.1-x86_64-unknown-linux-musl.tar.gz" \
|
||||
| tar xz -C /tmp \
|
||||
&& mv /tmp/sccache-v0.8.1-x86_64-unknown-linux-musl/sccache /usr/local/bin/sccache \
|
||||
&& rm -rf /tmp/sccache-v0.8.1-x86_64-unknown-linux-musl \
|
||||
&& chmod +x /usr/local/bin/sccache
|
||||
@@ -75,12 +75,27 @@ if [ -d "$RES_DIR" ]; then
|
||||
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
|
||||
fi
|
||||
|
||||
# Derive versionCode/versionName from VERSION_NAME env var (e.g. "v0.28.0" → code 2800, name "0.28.0").
|
||||
# AndroidManifest.xml intentionally has no versionCode/versionName — aapt2's --version-* flags only
|
||||
# inject when absent, so the manifest must be clean for CI injection to work. Local debug builds
|
||||
# fall back to code=1 / name="0.0.0-dev".
|
||||
if [ -n "${VERSION_NAME:-}" ]; then
|
||||
VN="${VERSION_NAME#v}"
|
||||
IFS='.' read -r _MAJ _MIN _PAT <<< "$VN"
|
||||
VERSION_CODE=$(( ${_MAJ:-0} * 10000 + ${_MIN:-0} * 100 + ${_PAT:-0} ))
|
||||
else
|
||||
VERSION_CODE=1
|
||||
VERSION_NAME="0.0.0-dev"
|
||||
fi
|
||||
|
||||
LINK_ARGS=(
|
||||
link
|
||||
-o "$STAGING/app-unsigned.apk"
|
||||
-I "$PLATFORM_JAR"
|
||||
--manifest "$MANIFEST"
|
||||
)
|
||||
[ -n "$VERSION_CODE" ] && LINK_ARGS+=( --version-code "$VERSION_CODE" )
|
||||
[ -n "${VERSION_NAME:-}" ] && LINK_ARGS+=( --version-name "${VERSION_NAME#v}" )
|
||||
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
|
||||
# Add compiled resources if any
|
||||
shopt -s nullglob
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
shared object name without the `lib` prefix or `.so` suffix.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.ferrousapp.solitaire"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0">
|
||||
package="com.ferrousapp.solitaire">
|
||||
|
||||
<uses-sdk
|
||||
android:minSdkVersion="26"
|
||||
|
||||
@@ -96,7 +96,7 @@ fn main() {
|
||||
continue;
|
||||
}
|
||||
let cfg = SolverConfig { move_budget, state_budget };
|
||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
||||
match try_solve(seed, draw_mode, &cfg) {
|
||||
SolverResult::Winnable => {
|
||||
buckets[i].push(seed);
|
||||
eprintln!(
|
||||
|
||||
@@ -73,7 +73,7 @@ fn main() {
|
||||
while found.len() < count {
|
||||
tried += 1;
|
||||
if matches!(
|
||||
try_solve(seed, draw_mode.clone(), &cfg),
|
||||
try_solve(seed, draw_mode, &cfg),
|
||||
SolverResult::Winnable
|
||||
) {
|
||||
found.push(seed);
|
||||
|
||||
@@ -8,9 +8,11 @@
|
||||
//! walks `ALL_ACHIEVEMENTS`, evaluates each `condition`, and emits an
|
||||
//! unlock event for any `AchievementDef` whose record is not yet unlocked.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Fields needed by achievement conditions. Constructed by the engine from
|
||||
/// `StatsSnapshot`, the final `GameState`, and wall-clock time.
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct AchievementContext {
|
||||
/// Total number of games played (after this win has been recorded).
|
||||
pub games_played: u32,
|
||||
|
||||
@@ -31,7 +31,8 @@ mod pile_map_serde {
|
||||
use crate::pile::{Pile, PileType};
|
||||
|
||||
pub fn serialize<S: Serializer>(map: &HashMap<PileType, Pile>, s: S) -> Result<S::Ok, S::Error> {
|
||||
let entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
||||
let mut entries: Vec<(&PileType, &Pile)> = map.iter().collect();
|
||||
entries.sort_by_key(|(k, _)| *k);
|
||||
entries.serialize(s)
|
||||
}
|
||||
|
||||
@@ -42,7 +43,7 @@ mod pile_map_serde {
|
||||
}
|
||||
|
||||
/// Whether cards are drawn one at a time or three at a time from the stock.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DrawMode {
|
||||
/// Draw one card from stock per turn.
|
||||
DrawOne,
|
||||
@@ -154,6 +155,7 @@ pub struct GameState {
|
||||
/// [`GAME_STATE_SCHEMA_VERSION`].
|
||||
#[serde(default = "schema_v1")]
|
||||
pub schema_version: u32,
|
||||
#[serde(skip)]
|
||||
undo_stack: VecDeque<StateSnapshot>,
|
||||
}
|
||||
|
||||
@@ -224,10 +226,10 @@ impl GameState {
|
||||
return Err(MoveError::GameAlreadyWon);
|
||||
}
|
||||
|
||||
let stock_len = self.piles[&PileType::Stock].cards.len();
|
||||
let stock_len = self.piles.get(&PileType::Stock).ok_or(MoveError::InvalidSource)?.cards.len();
|
||||
|
||||
if stock_len == 0 {
|
||||
let waste_len = self.piles[&PileType::Waste].cards.len();
|
||||
let waste_len = self.piles.get(&PileType::Waste).ok_or(MoveError::InvalidSource)?.cards.len();
|
||||
if waste_len == 0 {
|
||||
return Err(MoveError::StockEmpty);
|
||||
}
|
||||
@@ -245,7 +247,7 @@ impl GameState {
|
||||
stock.cards.push(card);
|
||||
}
|
||||
self.recycle_count = self.recycle_count.saturating_add(1);
|
||||
self.move_count += 1;
|
||||
self.move_count = self.move_count.saturating_add(1);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -271,7 +273,7 @@ impl GameState {
|
||||
waste.cards.push(card);
|
||||
}
|
||||
|
||||
self.move_count += 1;
|
||||
self.move_count = self.move_count.saturating_add(1);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -379,7 +381,7 @@ impl GameState {
|
||||
self.piles.get_mut(&to).ok_or(MoveError::InvalidDestination)?.cards.append(&mut moved);
|
||||
|
||||
self.score = (self.score + score_delta).max(0);
|
||||
self.move_count += 1;
|
||||
self.move_count = self.move_count.saturating_add(1);
|
||||
|
||||
self.is_won = self.check_win();
|
||||
if !self.is_won {
|
||||
@@ -428,14 +430,13 @@ impl GameState {
|
||||
pub fn check_auto_complete(&self) -> bool {
|
||||
// Stock must be empty; waste may still have cards (they are resolved
|
||||
// by draw() calls inside next_auto_complete_move / auto_complete_step).
|
||||
if !self.piles[&PileType::Stock].cards.is_empty() {
|
||||
if self.piles.get(&PileType::Stock).is_none_or(|p| !p.cards.is_empty()) {
|
||||
return false;
|
||||
}
|
||||
(0..7).all(|i| {
|
||||
self.piles[&PileType::Tableau(i)]
|
||||
.cards
|
||||
.iter()
|
||||
.all(|c| c.face_up)
|
||||
self.piles
|
||||
.get(&PileType::Tableau(i))
|
||||
.is_some_and(|p| p.cards.iter().all(|c| c.face_up))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -461,7 +462,8 @@ impl GameState {
|
||||
// Check waste top first — when stock is exhausted the waste may still
|
||||
// contain cards that can go directly to a foundation.
|
||||
let waste = PileType::Waste;
|
||||
if let Some((card, slot)) = self.piles[&waste].cards.last()
|
||||
if let Some((card, slot)) = self.piles.get(&waste)
|
||||
.and_then(|p| p.cards.last())
|
||||
.and_then(|c| self.foundation_slot_for(c).map(|s| (c, s)))
|
||||
{
|
||||
let _ = card; // borrow ends here
|
||||
@@ -469,7 +471,8 @@ impl GameState {
|
||||
}
|
||||
for i in 0..7 {
|
||||
let tableau = PileType::Tableau(i);
|
||||
if let Some(slot) = self.piles[&tableau].cards.last()
|
||||
if let Some(slot) = self.piles.get(&tableau)
|
||||
.and_then(|p| p.cards.last())
|
||||
.and_then(|c| self.foundation_slot_for(c))
|
||||
{
|
||||
return Some((tableau, PileType::Foundation(slot)));
|
||||
@@ -487,7 +490,7 @@ impl GameState {
|
||||
let mut candidate: Option<u8> = None;
|
||||
let mut empty_slot: Option<u8> = None;
|
||||
for slot in 0..4_u8 {
|
||||
let pile = &self.piles[&PileType::Foundation(slot)];
|
||||
let Some(pile) = self.piles.get(&PileType::Foundation(slot)) else { continue };
|
||||
if pile.cards.is_empty() {
|
||||
if empty_slot.is_none() {
|
||||
empty_slot = Some(slot);
|
||||
@@ -501,7 +504,8 @@ impl GameState {
|
||||
if card.rank.value() == 1 { empty_slot } else { None }
|
||||
});
|
||||
target.filter(|&slot| {
|
||||
can_place_on_foundation(card, &self.piles[&PileType::Foundation(slot)])
|
||||
self.piles.get(&PileType::Foundation(slot))
|
||||
.is_some_and(|p| can_place_on_foundation(card, p))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::card::{Card, Suit};
|
||||
|
||||
/// Identifies which pile on the board a set of cards belongs to.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
pub enum PileType {
|
||||
/// The face-down draw pile.
|
||||
Stock,
|
||||
|
||||
@@ -9,6 +9,7 @@ use crate::pile::Pile;
|
||||
/// [`Pile::claimed_suit`](crate::pile::Pile::claimed_suit)).
|
||||
/// - When the pile is non-empty, the next card must match the top card's
|
||||
/// suit and be exactly one rank higher.
|
||||
#[must_use]
|
||||
pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank.value() == 1,
|
||||
@@ -19,6 +20,7 @@ pub fn can_place_on_foundation(card: &Card, pile: &Pile) -> bool {
|
||||
/// Returns `true` if `card` (or the bottom card of a sequence) can be placed on `pile` in the tableau.
|
||||
///
|
||||
/// Tableau rules: Kings go on empty piles; otherwise alternating colour, one rank lower.
|
||||
#[must_use]
|
||||
pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
match pile.cards.last() {
|
||||
None => card.rank.value() == 13,
|
||||
@@ -36,6 +38,7 @@ pub fn can_place_on_tableau(card: &Card, pile: &Pile) -> bool {
|
||||
/// only validates the sequence's *internal* structure, which the tableau
|
||||
/// move path must enforce so a player can't smuggle an arbitrary stack
|
||||
/// onto another column when the bottom card happens to land legally.
|
||||
#[must_use]
|
||||
pub fn is_valid_tableau_sequence(cards: &[Card]) -> bool {
|
||||
cards.windows(2).all(|w| {
|
||||
w[0].rank.value() == w[1].rank.value() + 1 && w[0].suit.is_red() != w[1].suit.is_red()
|
||||
|
||||
@@ -665,7 +665,7 @@ impl SolverState {
|
||||
foundation,
|
||||
stock,
|
||||
waste,
|
||||
draw_mode: game.draw_mode.clone(),
|
||||
draw_mode: game.draw_mode,
|
||||
just_drew: false,
|
||||
consecutive_draws: 0,
|
||||
}
|
||||
|
||||
@@ -10,12 +10,11 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
pub use solitaire_sync::AchievementRecord;
|
||||
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const FILE_NAME: &str = "achievements.json";
|
||||
|
||||
/// Platform-specific default path for `achievements.json`.
|
||||
pub fn achievements_file_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load achievements from an explicit path. Returns `Vec::new()` if the file
|
||||
|
||||
@@ -295,9 +295,9 @@ fn read_file_bytes() -> Result<Vec<u8>, TokenError> {
|
||||
fn write_file_bytes(data: &[u8]) -> Result<(), TokenError> {
|
||||
let path = token_file_path()
|
||||
.ok_or_else(|| TokenError::KeychainUnavailable("no data dir".into()))?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
let tmp = path.with_extension("bin.tmp");
|
||||
std::fs::write(&tmp, data)
|
||||
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.tmp: {e}")))?;
|
||||
.map_err(|e| TokenError::Keyring(format!("write auth_tokens.bin.tmp: {e}")))?;
|
||||
std::fs::rename(&tmp, &path)
|
||||
.map_err(|e| TokenError::Keyring(format!("rename auth_tokens: {e}")))
|
||||
}
|
||||
|
||||
@@ -168,3 +168,6 @@ pub use matomo_client::MatomoClient;
|
||||
|
||||
pub mod platform;
|
||||
pub use platform::data_dir;
|
||||
|
||||
/// Application data subdirectory name, shared by all persistence modules.
|
||||
pub(crate) const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
|
||||
@@ -111,12 +111,12 @@ impl MatomoClient {
|
||||
}
|
||||
|
||||
fn url_encode(s: &str) -> String {
|
||||
s.chars()
|
||||
.flat_map(|c| match c {
|
||||
'A'..='Z' | 'a'..='z' | '0'..='9' | '-' | '_' | '.' | '~' => {
|
||||
vec![c]
|
||||
s.bytes()
|
||||
.flat_map(|b| match b {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
vec![b as char]
|
||||
}
|
||||
c => format!("%{:02X}", c as u32).chars().collect(),
|
||||
b => format!("%{b:02X}").chars().collect(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ use chrono::{Datelike, NaiveDate};
|
||||
pub use solitaire_sync::progress::level_for_xp;
|
||||
pub use solitaire_sync::PlayerProgress;
|
||||
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const FILE_NAME: &str = "progress.json";
|
||||
|
||||
/// Deterministic seed derived from a date, identical for all players globally.
|
||||
@@ -46,7 +45,7 @@ pub fn xp_for_win(time_seconds: u64, used_undo: bool) -> u64 {
|
||||
|
||||
/// Platform-specific default path for `progress.json`.
|
||||
pub fn progress_file_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load progress from an explicit path. Returns `default()` if missing/corrupt.
|
||||
|
||||
@@ -29,7 +29,6 @@ use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
|
||||
@@ -279,14 +278,14 @@ impl ReplayHistory {
|
||||
in migrate_legacy_latest_replay"
|
||||
)]
|
||||
pub fn latest_replay_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(LATEST_REPLAY_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Returns the platform-specific path to `replays.json`, the rolling
|
||||
/// history file, or `None` if `crate::data_dir()` is unavailable (e.g.
|
||||
/// minimal Linux containers).
|
||||
pub fn replay_history_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(REPLAY_HISTORY_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Save a [`Replay`] atomically to `path` using the standard `.tmp` →
|
||||
|
||||
@@ -11,7 +11,6 @@ use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
|
||||
/// Animation playback speed for card transitions.
|
||||
@@ -275,7 +274,7 @@ fn default_music_volume() -> f32 {
|
||||
}
|
||||
|
||||
fn default_theme_id() -> String {
|
||||
"dark".to_string()
|
||||
"classic".to_string()
|
||||
}
|
||||
|
||||
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||
@@ -402,11 +401,10 @@ impl Settings {
|
||||
/// their respective ranges after deserialization or hand-editing of
|
||||
/// `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
// Migrate stale theme IDs: "default" was removed when the theme was
|
||||
// renamed to "dark"; "classic" was briefly the default before "dark"
|
||||
// was restored as the shipped default.
|
||||
// Migrate stale theme IDs: "default" was the original name before it
|
||||
// was renamed to "dark".
|
||||
let selected_theme_id = match self.selected_theme_id.as_str() {
|
||||
"default" | "classic" => "dark".to_string(),
|
||||
"default" => "dark".to_string(),
|
||||
_ => self.selected_theme_id,
|
||||
};
|
||||
Self {
|
||||
@@ -480,7 +478,7 @@ impl Settings {
|
||||
/// Returns the platform-specific path to `settings.json`, or `None` if
|
||||
/// the platform's data directory is unavailable.
|
||||
pub fn settings_file_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(SETTINGS_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load settings from an explicit path. Returns `Settings::default()` if the
|
||||
|
||||
@@ -13,7 +13,6 @@ use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const STATS_FILE_NAME: &str = "stats.json";
|
||||
const GAME_STATE_FILE_NAME: &str = "game_state.json";
|
||||
const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
||||
@@ -21,7 +20,7 @@ const TIME_ATTACK_SESSION_FILE_NAME: &str = "time_attack_session.json";
|
||||
/// Returns the platform-specific path to `stats.json`, or `None` if
|
||||
/// `crate::data_dir()` is unavailable (e.g. minimal Linux containers).
|
||||
pub fn stats_file_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(STATS_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(STATS_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load stats from an explicit path. Returns `StatsSnapshot::default()` if
|
||||
@@ -71,7 +70,7 @@ pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
|
||||
/// Returns the platform-specific path to `game_state.json`, or `None` if
|
||||
/// `crate::data_dir()` is unavailable.
|
||||
pub fn game_state_file_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(GAME_STATE_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Load an in-progress `GameState` from `path`. Returns `None` if the file is
|
||||
@@ -123,14 +122,14 @@ pub fn delete_game_state_at(path: &Path) -> io::Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove any leftover `*.json.tmp` files in the app data directory.
|
||||
/// Remove any leftover `*.tmp` files in the app data directory.
|
||||
///
|
||||
/// These can be left behind if the process crashes between the write and rename
|
||||
/// in an atomic save. Safe to call on startup; missing or unreadable entries
|
||||
/// are silently skipped.
|
||||
pub fn cleanup_orphaned_tmp_files() -> io::Result<()> {
|
||||
let dir = match crate::data_dir() {
|
||||
Some(d) => d.join(APP_DIR_NAME),
|
||||
Some(d) => d.join(crate::APP_DIR_NAME),
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
@@ -181,7 +180,7 @@ pub struct TimeAttackSession {
|
||||
/// Returns the platform-specific path to `time_attack_session.json`, or
|
||||
/// `None` if `crate::data_dir()` is unavailable.
|
||||
pub fn time_attack_session_path() -> Option<PathBuf> {
|
||||
crate::data_dir().map(|d| d.join(APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
||||
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(TIME_ATTACK_SESSION_FILE_NAME))
|
||||
}
|
||||
|
||||
/// Save a Time Attack session atomically. Mirrors `save_game_state_to`'s
|
||||
@@ -267,7 +266,7 @@ pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttac
|
||||
}
|
||||
}
|
||||
|
||||
/// Inner helper: delete `*.json.tmp` entries inside `dir`.
|
||||
/// Inner helper: delete `*.tmp` entries inside `dir`.
|
||||
///
|
||||
/// Per-file errors (already deleted, permission denied) are silently ignored.
|
||||
fn cleanup_tmp_files_in(dir: &Path) {
|
||||
@@ -277,7 +276,7 @@ fn cleanup_tmp_files_in(dir: &Path) {
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.is_some_and(|n| n.ends_with(".json.tmp"))
|
||||
.is_some_and(|n| n.ends_with(".tmp"))
|
||||
{
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
|
||||
@@ -309,6 +309,9 @@ impl SyncProvider for SolitaireServerClient {
|
||||
async fn opt_in_leaderboard(&self, display_name: &str) -> Result<(), SyncError> {
|
||||
let token = self.access_token()?;
|
||||
let url = format!("{}/api/leaderboard/opt-in", self.base_url);
|
||||
// Enforce the server's 32-char column limit at the client boundary so
|
||||
// the server never receives an over-length name regardless of caller.
|
||||
let display_name: String = display_name.chars().take(32).collect();
|
||||
|
||||
let resp = self
|
||||
.client
|
||||
|
||||
@@ -12,7 +12,7 @@ use solitaire_core::game_state::GameMode;
|
||||
use solitaire_data::{matomo_client::MatomoClient, settings::SyncBackend, Settings};
|
||||
|
||||
use crate::events::{AchievementUnlockedEvent, ForfeitEvent, GameWonEvent, NewGameRequestEvent};
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::resources::{GameStateResource, TokioRuntimeResource};
|
||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -45,6 +45,7 @@ pub struct AnalyticsPlugin;
|
||||
impl Plugin for AnalyticsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<AnalyticsResource>()
|
||||
.init_resource::<TokioRuntimeResource>()
|
||||
.add_systems(Startup, init_analytics)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -80,28 +81,28 @@ fn react_to_settings_change(
|
||||
fn on_game_won(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
) {
|
||||
let Some(client) = analytics.client.clone() else {
|
||||
return;
|
||||
};
|
||||
for ev in wins.read() {
|
||||
client.event("Game", "Won", None, Some(ev.score as f64));
|
||||
fire_flush(client.clone(), &settings.0);
|
||||
fire_flush(client.clone(), rt.0.clone());
|
||||
}
|
||||
}
|
||||
|
||||
fn on_forfeit(
|
||||
mut forfeits: MessageReader<ForfeitEvent>,
|
||||
analytics: Res<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
) {
|
||||
let Some(client) = analytics.client.clone() else {
|
||||
return;
|
||||
};
|
||||
for _ev in forfeits.read() {
|
||||
client.event("Game", "Forfeit", None, None);
|
||||
fire_flush(client.clone(), &settings.0);
|
||||
fire_flush(client.clone(), rt.0.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,14 +138,14 @@ fn on_achievement_unlocked(
|
||||
fn tick_flush_timer(
|
||||
time: Res<Time>,
|
||||
mut analytics: ResMut<AnalyticsResource>,
|
||||
settings: Res<SettingsResource>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
) {
|
||||
analytics.flush_timer.tick(time.delta());
|
||||
if !analytics.flush_timer.just_finished() {
|
||||
return;
|
||||
}
|
||||
if let Some(client) = analytics.client.clone() {
|
||||
fire_flush(client, &settings.0);
|
||||
fire_flush(client, rt.0.clone());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,15 +165,10 @@ fn client_for(settings: &Settings) -> Option<Arc<MatomoClient>> {
|
||||
Some(Arc::new(MatomoClient::new(url, settings.matomo_site_id, uid)))
|
||||
}
|
||||
|
||||
fn fire_flush(client: Arc<MatomoClient>, _settings: &Settings) {
|
||||
fn fire_flush(client: Arc<MatomoClient>, rt: Arc<tokio::runtime::Runtime>) {
|
||||
AsyncComputeTaskPool::get()
|
||||
.spawn(async move {
|
||||
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
rt.block_on(client.flush());
|
||||
}
|
||||
})
|
||||
.detach();
|
||||
}
|
||||
|
||||
@@ -454,8 +454,8 @@ fn handle_settings_toast(
|
||||
for ev in events.read() {
|
||||
let sfx = ev.0.sfx_volume;
|
||||
let music = ev.0.music_volume;
|
||||
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > f32::EPSILON);
|
||||
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > f32::EPSILON);
|
||||
let sfx_changed = last_sfx.is_none_or(|prev| (prev - sfx).abs() > 0.001);
|
||||
let music_changed = last_music.is_none_or(|prev| (prev - music).abs() > 0.001);
|
||||
*last_sfx = Some(sfx);
|
||||
*last_music = Some(music);
|
||||
if sfx_changed {
|
||||
|
||||
@@ -21,6 +21,8 @@ use bevy::asset::RenderAssetUsages;
|
||||
use bevy::prelude::*;
|
||||
use bevy::tasks::{futures_lite::future, AsyncComputeTaskPool, Task};
|
||||
|
||||
use crate::resources::TokioRuntimeResource;
|
||||
|
||||
/// Stores the loaded avatar [`Handle<Image>`], or `None` when no avatar
|
||||
/// has been fetched yet (new account, no internet, or fetch in progress).
|
||||
#[derive(Resource, Default)]
|
||||
@@ -46,6 +48,7 @@ pub struct AvatarPlugin;
|
||||
impl Plugin for AvatarPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_message::<AvatarFetchEvent>()
|
||||
.init_resource::<TokioRuntimeResource>()
|
||||
.init_resource::<AvatarResource>()
|
||||
.init_resource::<PendingAvatarTask>()
|
||||
.add_systems(Update, (handle_avatar_fetch, poll_avatar_task));
|
||||
@@ -54,17 +57,15 @@ impl Plugin for AvatarPlugin {
|
||||
|
||||
fn handle_avatar_fetch(
|
||||
mut events: MessageReader<AvatarFetchEvent>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
mut pending: ResMut<PendingAvatarTask>,
|
||||
) {
|
||||
for ev in events.read() {
|
||||
// Cancel any in-flight task and restart with the new URL.
|
||||
let url = ev.url.clone();
|
||||
let rt = rt.0.clone();
|
||||
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.ok()?
|
||||
.block_on(async move {
|
||||
rt.block_on(async move {
|
||||
let client = reqwest::Client::new();
|
||||
let bytes = client
|
||||
.get(&url)
|
||||
|
||||
@@ -15,6 +15,8 @@ use std::collections::{HashMap, HashSet};
|
||||
use bevy::color::Color;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::WindowResized;
|
||||
#[cfg(target_os = "android")]
|
||||
use bevy::sprite::Anchor;
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
@@ -65,6 +67,12 @@ pub const STACK_FAN_FRAC: f32 = 0.003;
|
||||
/// 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 Android.
|
||||
/// 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.
|
||||
#[cfg(target_os = "android")]
|
||||
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`.
|
||||
@@ -163,6 +171,25 @@ pub struct CardEntity {
|
||||
#[derive(Component, Debug)]
|
||||
pub struct CardLabel;
|
||||
|
||||
/// Marker for the large-print rank+suit corner overlay on Android.
|
||||
///
|
||||
/// 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).
|
||||
#[cfg(target_os = "android")]
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
struct AndroidCornerLabel;
|
||||
|
||||
/// 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.
|
||||
#[cfg(target_os = "android")]
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
struct AndroidCornerBg;
|
||||
|
||||
/// 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.
|
||||
@@ -339,8 +366,8 @@ fn add_card_shadow_child(parent: &mut ChildSpawnerCommands, card_size: Vec2) {
|
||||
));
|
||||
}
|
||||
|
||||
/// Spawns a `CardBackFrame` child behind a face-down card entity so the dark
|
||||
/// back PNG has a visible perimeter against the dark felt.
|
||||
/// 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,
|
||||
@@ -424,14 +451,62 @@ impl Plugin for CardPlugin {
|
||||
clear_right_click_highlights_on_state_change.after(GameMutation),
|
||||
clear_right_click_highlights_on_pause,
|
||||
update_stock_empty_indicator.after(GameMutation),
|
||||
update_stock_count_badge.after(GameMutation),
|
||||
update_stock_count_badge
|
||||
.after(GameMutation)
|
||||
.run_if(resource_changed::<crate::GameStateResource>),
|
||||
collect_resize_events.after(LayoutSystem::UpdateOnResize),
|
||||
snap_cards_on_window_resize.after(collect_resize_events),
|
||||
),
|
||||
);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.add_systems(Update, resize_android_corner_labels);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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`].
|
||||
///
|
||||
@@ -446,21 +521,19 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
|
||||
return;
|
||||
};
|
||||
|
||||
// Suit index: Clubs=0, Diamonds=1, Hearts=2, Spades=3
|
||||
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
||||
// Rank index: Ace=0 … King=12
|
||||
const RANK_STRS: [&str; 13] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
|
||||
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
const RANKS: [Rank; 13] = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven,
|
||||
Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
|
||||
];
|
||||
|
||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
|
||||
std::array::from_fn(|rank| {
|
||||
asset_server.load(format!(
|
||||
"cards/faces/{}{}.png",
|
||||
RANK_STRS[rank], SUIT_CHARS[suit]
|
||||
))
|
||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
||||
std::array::from_fn(|ri| {
|
||||
asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si]))
|
||||
})
|
||||
});
|
||||
let backs = std::array::from_fn(|i| {
|
||||
asset_server.load(format!("cards/backs/back_{i}.png"))
|
||||
asset_server.load(format!("cards/backs/classic/back_{i}.png"))
|
||||
});
|
||||
commands.insert_resource(CardImageSet {
|
||||
faces,
|
||||
@@ -554,6 +627,7 @@ fn resync_cards_on_settings_change(
|
||||
/// 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)]
|
||||
fn sync_cards_startup(
|
||||
commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
@@ -562,6 +636,7 @@ fn sync_cards_startup(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
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);
|
||||
@@ -569,7 +644,8 @@ fn sync_cards_startup(
|
||||
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);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -583,6 +659,7 @@ fn sync_cards_on_change(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
@@ -593,7 +670,8 @@ fn sync_cards_on_change(
|
||||
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);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,6 +687,7 @@ fn sync_cards(
|
||||
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
@@ -638,10 +717,10 @@ fn sync_cards(
|
||||
Some(&(entity, cur, has_anim)) => {
|
||||
update_card_entity(
|
||||
&mut commands, entity, card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back,
|
||||
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
|
||||
)
|
||||
}
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back),
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -665,6 +744,19 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
|
||||
// Compute the Draw-Three waste fan step proportional to the column spacing
|
||||
// (waste_x − stock_x = card_width + h_gap) rather than a fixed fraction of
|
||||
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
|
||||
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
|
||||
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
|
||||
// the top fanned card's centre within the waste column's own horizontal
|
||||
// footprint instead of spilling into the adjacent gap.
|
||||
let waste_fan_step = {
|
||||
let s = layout.pile_positions.get(&PileType::Stock).copied().unwrap_or_default();
|
||||
let w = layout.pile_positions.get(&PileType::Waste).copied().unwrap_or_default();
|
||||
(w.x - s.x).abs() * 0.224
|
||||
};
|
||||
|
||||
for pile_type in piles {
|
||||
let Some(base) = layout.pile_positions.get(&pile_type) else {
|
||||
continue;
|
||||
@@ -706,7 +798,7 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
// 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 * layout.card_size.x * 0.28
|
||||
slot.saturating_sub(hidden) as f32 * waste_fan_step
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -738,6 +830,7 @@ fn spawn_card_entity(
|
||||
high_contrast: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
|
||||
|
||||
@@ -754,15 +847,15 @@ fn spawn_card_entity(
|
||||
entity.with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
// Face-down cards get a thin contrasting border frame so the dark back
|
||||
// PNG reads as a distinct rectangle against the dark felt.
|
||||
if !card.face_up {
|
||||
// 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((
|
||||
@@ -778,6 +871,15 @@ fn spawn_card_entity(
|
||||
));
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
if card_images.is_some() {
|
||||
entity.with_children(|b| {
|
||||
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||
});
|
||||
}
|
||||
// Suppress unused-variable warning when not building for Android.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let _ = font_handle;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -796,6 +898,7 @@ fn update_card_entity(
|
||||
has_card_animation: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
let target = Vec3::new(pos.x, pos.y, z);
|
||||
|
||||
@@ -831,16 +934,15 @@ fn update_card_entity(
|
||||
|
||||
// Despawn any stale children and re-add the per-card drop shadow plus,
|
||||
// in solid-colour fallback mode, the label overlay. In image mode the
|
||||
// rank/suit are baked into the PNG, so no `Text2d` overlay is needed.
|
||||
// rank/suit are baked into the PNG; on Android we also add a large-print
|
||||
// corner overlay so they are legible at phone scale.
|
||||
commands.entity(entity).despawn_related::<Children>();
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_card_shadow_child(b, layout.card_size);
|
||||
});
|
||||
if !card.face_up {
|
||||
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((
|
||||
@@ -856,6 +958,15 @@ fn update_card_entity(
|
||||
));
|
||||
});
|
||||
}
|
||||
#[cfg(target_os = "android")]
|
||||
if card_images.is_some() {
|
||||
commands.entity(entity).with_children(|b| {
|
||||
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||
});
|
||||
}
|
||||
// Suppress unused-variable warning when not building for Android.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let _ = font_handle;
|
||||
}
|
||||
|
||||
fn label_for(card: &Card) -> String {
|
||||
@@ -928,6 +1039,101 @@ fn label_visibility(card: &Card) -> Visibility {
|
||||
}
|
||||
}
|
||||
|
||||
/// Rank+suit string for the Android readability overlay.
|
||||
/// Uses Unicode suit glyphs (♠♥♦♣ — U+2660–U+2666, covered by FiraMono).
|
||||
#[cfg(target_os = "android")]
|
||||
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).
|
||||
#[cfg(target_os = "android")]
|
||||
fn add_android_corner_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
card: &Card,
|
||||
card_size: Vec2,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
if !card.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;
|
||||
|
||||
// Solid background that hides the card art's small corner label.
|
||||
parent.spawn((
|
||||
AndroidCornerBg,
|
||||
Sprite {
|
||||
color: CARD_FACE_COLOUR,
|
||||
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.
|
||||
parent.spawn((
|
||||
AndroidCornerLabel,
|
||||
CardLabel,
|
||||
Text2d::new(mobile_label_for(card)),
|
||||
TextFont {
|
||||
font: font_handle.cloned().unwrap_or_default(),
|
||||
font_size,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1394,6 +1600,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||
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
|
||||
.piles
|
||||
@@ -1419,7 +1626,7 @@ fn apply_stock_empty_indicator<F: bevy::ecs::query::QueryFilter>(
|
||||
b.spawn((
|
||||
StockEmptyLabel,
|
||||
Text2d::new("↺"),
|
||||
TextFont { font_size, ..default() },
|
||||
TextFont { font: font.clone(), font_size, ..default() },
|
||||
TextColor(TEXT_PRIMARY.with_alpha(0.7)),
|
||||
Transform::from_xyz(0.0, 0.0, 0.1),
|
||||
));
|
||||
@@ -1445,16 +1652,19 @@ 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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1465,6 +1675,7 @@ fn update_stock_empty_indicator(
|
||||
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>>,
|
||||
) {
|
||||
@@ -1472,12 +1683,14 @@ fn update_stock_empty_indicator(
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1492,10 +1705,11 @@ fn update_stock_empty_indicator(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
|
||||
/// the centre of the count badge. A small inward offset keeps the chip from
|
||||
/// drifting half-off the card while still reading as "attached" to the
|
||||
/// corner.
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
||||
/// the centre of the count badge. 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.
|
||||
@@ -1687,6 +1901,7 @@ fn snap_cards_on_window_resize(
|
||||
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>),
|
||||
@@ -1735,12 +1950,14 @@ fn snap_cards_on_window_resize(
|
||||
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;
|
||||
@@ -1836,6 +2053,43 @@ fn resize_cards_in_place(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[cfg(target_os = "android")]
|
||||
fn resize_android_corner_labels(
|
||||
layout: Res<LayoutResource>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
mut text_query: Query<(&mut TextFont, &mut Transform), With<AndroidCornerLabel>>,
|
||||
mut bg_query: Query<
|
||||
(&mut Sprite, &mut Transform),
|
||||
(With<AndroidCornerBg>, Without<AndroidCornerLabel>),
|
||||
>,
|
||||
) {
|
||||
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 (mut font, mut transform) in text_query.iter_mut() {
|
||||
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` to match the current maximum
|
||||
/// face-up column depth. Runs after every `StateChangedEvent` so the fan
|
||||
/// expands as the player reveals cards while staying within the window.
|
||||
@@ -3007,4 +3261,230 @@ mod tests {
|
||||
assert!((highlight.blue - success.blue).abs() < 1e-6);
|
||||
assert!((highlight.alpha - 0.6).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bug #1 — CardImageSet key lookup (code-side mapping)
|
||||
//
|
||||
// These tests verify that every (Rank, Suit) pair produces the expected
|
||||
// filename via `card_face_asset_path`. They can only detect *code-side*
|
||||
// mapping bugs (e.g. a suit index mismatch). They do NOT inspect pixel
|
||||
// data — if `QS.png` contains a diamond watermark that is an *asset
|
||||
// content* bug that requires replacing the PNG file.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_queen_of_spades_is_qs_png() {
|
||||
assert_eq!(
|
||||
card_face_asset_path(Rank::Queen, Suit::Spades),
|
||||
"cards/faces/classic/QS.png",
|
||||
"Queen of Spades must resolve to QS.png, not QD.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_queen_of_diamonds_is_qd_png() {
|
||||
assert_eq!(
|
||||
card_face_asset_path(Rank::Queen, Suit::Diamonds),
|
||||
"cards/faces/classic/QD.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_ace_of_clubs_is_ac_png() {
|
||||
assert_eq!(card_face_asset_path(Rank::Ace, Suit::Clubs), "cards/faces/classic/AC.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_ten_of_hearts_is_10h_png() {
|
||||
assert_eq!(card_face_asset_path(Rank::Ten, Suit::Hearts), "cards/faces/classic/10H.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_king_of_spades_is_ks_png() {
|
||||
assert_eq!(card_face_asset_path(Rank::King, Suit::Spades), "cards/faces/classic/KS.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_all_52_keys_are_unique() {
|
||||
use std::collections::HashSet;
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let ranks = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
|
||||
];
|
||||
let paths: HashSet<String> = suits
|
||||
.iter()
|
||||
.flat_map(|&s| ranks.iter().map(move |&r| card_face_asset_path(r, s)))
|
||||
.collect();
|
||||
assert_eq!(paths.len(), 52, "all 52 card face paths must be distinct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_suits_produce_correct_suffix() {
|
||||
// Each suit must map to its own letter, not a neighbour's.
|
||||
assert!(card_face_asset_path(Rank::Ace, Suit::Clubs).ends_with("AC.png"));
|
||||
assert!(card_face_asset_path(Rank::Ace, Suit::Diamonds).ends_with("AD.png"));
|
||||
assert!(card_face_asset_path(Rank::Ace, Suit::Hearts).ends_with("AH.png"));
|
||||
assert!(card_face_asset_path(Rank::Ace, Suit::Spades).ends_with("AS.png"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bug #3 — Suit → color mapping for the Android corner overlay
|
||||
//
|
||||
// Black suits (♠♣) must use BLACK_SUIT_COLOUR (near-white) so they
|
||||
// contrast against the dark card face. They must NOT share the red or
|
||||
// lime colours assigned to red suits.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn text_colour_black_suits_are_near_white_not_red() {
|
||||
for suit in [Suit::Clubs, Suit::Spades] {
|
||||
let card = Card { id: 0, suit, rank: Rank::Ace, face_up: true };
|
||||
let colour = text_colour(&card, false, false);
|
||||
assert_eq!(
|
||||
colour, BLACK_SUIT_COLOUR,
|
||||
"{suit:?} must map to BLACK_SUIT_COLOUR (near-white)"
|
||||
);
|
||||
assert_ne!(
|
||||
colour, RED_SUIT_COLOUR,
|
||||
"{suit:?} must not use the red suit colour"
|
||||
);
|
||||
// Confirm it's visually light (all channels > 0.85).
|
||||
let srgba = colour.to_srgba();
|
||||
assert!(
|
||||
srgba.red > 0.85 && srgba.green > 0.85 && srgba.blue > 0.85,
|
||||
"{suit:?} colour must be near-white for dark card background contrast, got {srgba:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bug #4 — Waste pile z-ordering
|
||||
//
|
||||
// Every rendered waste card must have a strictly greater z than the one
|
||||
// below it so Bevy's CPU-side sprite sort renders them back-to-front.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn waste_pile_cards_have_strictly_increasing_z() {
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
for _ in 0..5 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.map(|(_, _, z)| *z)
|
||||
.collect();
|
||||
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
waste_zs.dedup();
|
||||
|
||||
assert!(
|
||||
waste_zs.len() >= 2,
|
||||
"expected multiple rendered waste cards, got {}",
|
||||
waste_zs.len()
|
||||
);
|
||||
// All z values must be strictly ordered (no duplicates).
|
||||
for w in waste_zs.windows(2) {
|
||||
assert!(
|
||||
w[1] > w[0],
|
||||
"waste z values must be strictly increasing, got {} ≤ {}",
|
||||
w[1],
|
||||
w[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: on tight layouts (e.g. Android H_GAP_DIVISOR=32) the
|
||||
/// Draw-Three waste fan must be proportional to column spacing so that no
|
||||
/// fanned card ever bleeds left into the stock column.
|
||||
///
|
||||
/// The invariant holds structurally (x_offset ≥ 0), but this test pins
|
||||
/// the formula so a future change that accidentally introduces negative
|
||||
/// offsets or flips the fan direction is caught immediately.
|
||||
#[test]
|
||||
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
for _ in 0..5 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
|
||||
// Android-portrait window. In host tests H_GAP_DIVISOR uses the
|
||||
// desktop value (4), but the no-overlap invariant must hold on any
|
||||
// screen size and gap ratio.
|
||||
let window = Vec2::new(900.0, 2000.0);
|
||||
let layout = crate::layout::compute_layout(window, 32.0, 110.0, true);
|
||||
|
||||
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||
let stock_right_edge = stock_x + layout.card_size.x / 2.0;
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
let positions = card_positions(&g, &layout);
|
||||
for (card, pos, _) in positions.iter().filter(|(c, _, _)| waste_ids.contains(&c.id)) {
|
||||
let left_edge = pos.x - layout.card_size.x / 2.0;
|
||||
assert!(
|
||||
left_edge >= stock_right_edge - 1e-3,
|
||||
"waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window",
|
||||
card.id,
|
||||
left_edge,
|
||||
stock_right_edge,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waste_pile_draw_one_cards_have_distinct_z() {
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||
for _ in 0..3 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.map(|(_, _, z)| *z)
|
||||
.collect();
|
||||
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
waste_zs.dedup();
|
||||
|
||||
assert!(
|
||||
waste_zs.len() >= 2,
|
||||
"Draw-One must render at least 2 waste cards (visible + buffer)"
|
||||
);
|
||||
// Deduplicated length must equal pre-dedup length → all z distinct.
|
||||
let raw_count = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.count();
|
||||
assert_eq!(
|
||||
waste_zs.len(),
|
||||
raw_count,
|
||||
"all rendered waste card z values must be distinct"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
|
||||
|
||||
use crate::card_plugin::{RightClickHighlight, TABLEAU_FAN_FRAC};
|
||||
use crate::card_plugin::RightClickHighlight;
|
||||
use crate::layout::{Layout, LayoutResource};
|
||||
use crate::resources::{DragState, GameStateResource};
|
||||
use crate::table_plugin::{PileMarker, PILE_MARKER_DEFAULT_COLOUR};
|
||||
@@ -80,7 +80,7 @@ impl Plugin for CursorPlugin {
|
||||
Update,
|
||||
(
|
||||
update_cursor_icon,
|
||||
update_drop_highlights,
|
||||
update_drop_highlights.run_if(resource_changed::<crate::resources::DragState>),
|
||||
update_drop_target_overlays,
|
||||
),
|
||||
);
|
||||
@@ -387,7 +387,7 @@ fn drop_overlay_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec
|
||||
if matches!(pile, PileType::Tableau(_)) {
|
||||
let card_count = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
if card_count > 1 {
|
||||
let fan = -layout.card_size.y * TABLEAU_FAN_FRAC;
|
||||
let fan = -layout.card_size.y * layout.tableau_fan_frac;
|
||||
let bottom_card_centre_y = centre.y + fan * (card_count - 1) as f32;
|
||||
let top_edge = centre.y + layout.card_size.y / 2.0;
|
||||
let bottom_edge = bottom_card_centre_y - layout.card_size.y / 2.0;
|
||||
@@ -478,7 +478,7 @@ fn tableau_or_stack_pos(
|
||||
if is_tableau {
|
||||
Vec2::new(
|
||||
base.x,
|
||||
base.y - layout.card_size.y * TABLEAU_FAN_FRAC * (index as f32),
|
||||
base.y - layout.card_size.y * layout.tableau_fan_frac * (index as f32),
|
||||
)
|
||||
} else if matches!(pile, PileType::Waste) && game.draw_mode == DrawMode::DrawThree {
|
||||
let pile_len = game.piles.get(pile).map_or(0, |p| p.cards.len());
|
||||
|
||||
@@ -228,10 +228,15 @@ impl Plugin for FeedbackAnimPlugin {
|
||||
fn start_shake_anim(
|
||||
mut events: MessageReader<MoveRejectedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_entities: Query<(Entity, &CardEntity, &Transform)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||
for ev in events.read() {
|
||||
if reduce_motion {
|
||||
continue;
|
||||
}
|
||||
let dest_pile = &ev.to;
|
||||
// Collect the card ids that belong to the destination pile.
|
||||
let Some(pile) = game.0.piles.get(dest_pile) else { continue };
|
||||
@@ -489,11 +494,16 @@ pub fn foundation_flourish_scale(elapsed_secs: f32, duration_secs: f32) -> f32 {
|
||||
fn start_foundation_flourish(
|
||||
mut events: MessageReader<FoundationCompletedEvent>,
|
||||
game: Res<GameStateResource>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
card_entities: Query<(Entity, &CardEntity)>,
|
||||
mut pile_markers: Query<(Entity, &PileMarker, &Sprite, Option<&FoundationMarkerFlourish>)>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||
for ev in events.read() {
|
||||
if reduce_motion {
|
||||
continue;
|
||||
}
|
||||
let pile_type = PileType::Foundation(ev.slot);
|
||||
// Top card of the completed foundation is the King.
|
||||
let Some(king_id) = game
|
||||
@@ -785,7 +795,7 @@ mod tests {
|
||||
#[test]
|
||||
fn deal_stagger_jitter_varies_across_card_ids() {
|
||||
// 52 cards should produce more than a couple distinct jitter factors;
|
||||
// a constant function would return one value for all ids.
|
||||
// a constant function would return one function for all ids.
|
||||
use std::collections::HashSet;
|
||||
let unique: HashSet<u64> = (0u32..52)
|
||||
.map(|id| (deal_stagger_jitter(id) * 1e6) as i64 as u64)
|
||||
@@ -796,4 +806,96 @@ mod tests {
|
||||
unique.len()
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reduce-motion gates — ShakeAnim, FoundationFlourish
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// `start_shake_anim` must not insert `ShakeAnim` when `reduce_motion_mode`
|
||||
/// is on, even when the event targets a pile that has card entities present.
|
||||
#[test]
|
||||
fn shake_anim_skipped_under_reduce_motion() {
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
app.update();
|
||||
|
||||
// Pick a card from Tableau(0) so the event refers to a real pile.
|
||||
let dest_pile = PileType::Tableau(0);
|
||||
let card_id = app
|
||||
.world()
|
||||
.resource::<GameStateResource>()
|
||||
.0
|
||||
.piles
|
||||
.get(&dest_pile)
|
||||
.and_then(|p| p.cards.last())
|
||||
.map(|c| c.id)
|
||||
.expect("Tableau(0) should have at least one card in a fresh game");
|
||||
|
||||
// Spawn a minimal CardEntity matching that id so the system would
|
||||
// find it and insert ShakeAnim if the gate were absent.
|
||||
app.world_mut().spawn((
|
||||
CardEntity { card_id },
|
||||
Transform::default(),
|
||||
));
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<MoveRejectedEvent>>()
|
||||
.write(MoveRejectedEvent {
|
||||
from: PileType::Stock,
|
||||
to: dest_pile,
|
||||
count: 1,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let shake_count = app
|
||||
.world_mut()
|
||||
.query::<&ShakeAnim>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(shake_count, 0, "ShakeAnim must not be inserted under reduce-motion");
|
||||
}
|
||||
|
||||
/// `start_foundation_flourish` must not insert `FoundationFlourish` when
|
||||
/// `reduce_motion_mode` is on.
|
||||
#[test]
|
||||
fn foundation_flourish_skipped_under_reduce_motion() {
|
||||
use bevy::ecs::message::Messages;
|
||||
use solitaire_core::game_state::{DrawMode, GameState};
|
||||
use solitaire_data::Settings;
|
||||
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins)
|
||||
.add_plugins(FeedbackAnimPlugin);
|
||||
app.insert_resource(GameStateResource(GameState::new(1, DrawMode::DrawOne)));
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
app.update();
|
||||
|
||||
app.world_mut()
|
||||
.resource_mut::<Messages<FoundationCompletedEvent>>()
|
||||
.write(FoundationCompletedEvent {
|
||||
slot: 0,
|
||||
suit: solitaire_core::card::Suit::Spades,
|
||||
});
|
||||
app.update();
|
||||
|
||||
let flourish_count = app
|
||||
.world_mut()
|
||||
.query::<&FoundationFlourish>()
|
||||
.iter(app.world())
|
||||
.count();
|
||||
assert_eq!(flourish_count, 0, "FoundationFlourish must not be inserted under reduce-motion");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,11 +380,11 @@ fn poll_pending_new_game_seed(
|
||||
|
||||
/// Pure helper extracted for testability — `new_game_with_solver_*`
|
||||
/// engine tests in the same file exercise this path.
|
||||
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: &DrawMode) -> u64 {
|
||||
pub(crate) fn choose_winnable_seed(initial_seed: u64, draw_mode: DrawMode) -> u64 {
|
||||
let cfg = SolverConfig::default();
|
||||
let mut seed = initial_seed;
|
||||
for _ in 0..SOLVER_DEAL_RETRY_CAP {
|
||||
match try_solve(seed, draw_mode.clone(), &cfg) {
|
||||
match try_solve(seed, draw_mode, &cfg) {
|
||||
SolverResult::Winnable | SolverResult::Inconclusive => return seed,
|
||||
SolverResult::Unwinnable => {
|
||||
seed = seed.wrapping_add(1);
|
||||
@@ -451,7 +451,7 @@ fn handle_new_game(
|
||||
// where SettingsPlugin is not installed.
|
||||
let draw_mode = settings
|
||||
.as_ref()
|
||||
.map_or_else(|| game.0.draw_mode.clone(), |s| s.0.draw_mode.clone());
|
||||
.map_or_else(|| game.0.draw_mode, |s| s.0.draw_mode);
|
||||
let mode = ev.mode.unwrap_or(game.0.mode);
|
||||
|
||||
// Solver-backed retry: when the player has opted in to
|
||||
@@ -473,9 +473,8 @@ fn handle_new_game(
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.0.winnable_deals_only);
|
||||
if winnable_only && mode == GameMode::Classic && ev.seed.is_none() {
|
||||
let dm = draw_mode.clone();
|
||||
let task = AsyncComputeTaskPool::get()
|
||||
.spawn(async move { choose_winnable_seed(initial_seed, &dm) });
|
||||
.spawn(async move { choose_winnable_seed(initial_seed, draw_mode) });
|
||||
pending_seed.inner = Some(PendingSeedTask {
|
||||
handle: task,
|
||||
mode: ev.mode,
|
||||
@@ -970,7 +969,7 @@ pub fn record_replay_on_win(
|
||||
let win_move_index = recording.moves.len().checked_sub(1);
|
||||
let replay = Replay::new(
|
||||
game.0.seed,
|
||||
game.0.draw_mode.clone(),
|
||||
game.0.draw_mode,
|
||||
game.0.mode,
|
||||
ev.time_seconds,
|
||||
ev.score,
|
||||
@@ -1079,9 +1078,7 @@ fn check_no_moves(
|
||||
) {
|
||||
// Reset the debounce flag on every state change so if something changes
|
||||
// we re-evaluate on the next state change.
|
||||
let had_event = events.read().next().is_some();
|
||||
// Drain remaining events to avoid leaking.
|
||||
events.clear();
|
||||
let had_event = events.read().count() > 0;
|
||||
|
||||
if !had_event {
|
||||
return;
|
||||
@@ -2649,7 +2646,7 @@ mod tests {
|
||||
// resolves as Inconclusive — the engine treats Inconclusive
|
||||
// as winnable (see `choose_winnable_seed` doc), so the
|
||||
// helper must return 395 when started at 394.
|
||||
let chosen = choose_winnable_seed(394, &DrawMode::DrawOne);
|
||||
let chosen = choose_winnable_seed(394, DrawMode::DrawOne);
|
||||
assert_eq!(
|
||||
chosen, 395,
|
||||
"seed 394 is Unwinnable; the next seed (395, Inconclusive) must be accepted"
|
||||
|
||||
@@ -9,6 +9,8 @@ use bevy::prelude::*;
|
||||
|
||||
use crate::events::HelpRequestEvent;
|
||||
use crate::font_plugin::FontResource;
|
||||
#[cfg(target_os = "android")]
|
||||
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ScrimDismissible,
|
||||
@@ -158,7 +160,7 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
||||
ControlRow { keys: "←", description: "Undo last move" },
|
||||
ControlRow { keys: "||", description: "Pause / resume" },
|
||||
ControlRow { keys: "?", description: "This help screen" },
|
||||
ControlRow { keys: "→", description: "Show a hint" },
|
||||
ControlRow { keys: ANDROID_HINT_LABEL, description: "Show a hint" },
|
||||
ControlRow { keys: "≡", description: "Open menu (Stats, Settings, Profile...)" },
|
||||
],
|
||||
},
|
||||
@@ -346,6 +348,29 @@ fn spawn_help_screen(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Regression test for M-17: Android help screen showed "→" (right-arrow)
|
||||
/// for the Hint button when the actual HUD button label is "!".
|
||||
/// Verifies that the HUD Buttons section contains exactly one row whose
|
||||
/// `keys` matches `ANDROID_HINT_LABEL`.
|
||||
#[cfg(target_os = "android")]
|
||||
#[test]
|
||||
fn android_hint_row_matches_hud_label() {
|
||||
use crate::hud_plugin::ANDROID_HINT_LABEL;
|
||||
let hud_section = CONTROL_SECTIONS
|
||||
.iter()
|
||||
.find(|s| s.title == "HUD buttons")
|
||||
.expect("HUD buttons section must exist");
|
||||
let hint_row = hud_section
|
||||
.rows
|
||||
.iter()
|
||||
.find(|r| r.description == "Show a hint")
|
||||
.expect("hint row must exist");
|
||||
assert_eq!(
|
||||
hint_row.keys, ANDROID_HINT_LABEL,
|
||||
"help hint row must match the HUD button label"
|
||||
);
|
||||
}
|
||||
|
||||
fn headless_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(HelpPlugin);
|
||||
|
||||
@@ -35,6 +35,7 @@ use crate::stats_plugin::StatsResource;
|
||||
use crate::ui_focus::{Disabled, FocusGroup, Focusable};
|
||||
use crate::ui_modal::{
|
||||
spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header, ButtonVariant,
|
||||
ModalButton,
|
||||
ScrimDismissible,
|
||||
};
|
||||
use crate::ui_theme::{
|
||||
@@ -373,6 +374,7 @@ fn toggle_home_screen(
|
||||
daily: Option<Res<DailyChallengeResource>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||
diff_expanded: Res<DifficultyExpanded>,
|
||||
) {
|
||||
if !keys.just_pressed(KeyCode::KeyM) {
|
||||
@@ -380,7 +382,7 @@ fn toggle_home_screen(
|
||||
}
|
||||
if let Ok(entity) = screens.single() {
|
||||
commands.entity(entity).despawn();
|
||||
} else {
|
||||
} else if other_modal_scrims.is_empty() {
|
||||
spawn_home_screen(
|
||||
&mut commands,
|
||||
build_home_context(
|
||||
@@ -428,7 +430,7 @@ fn build_home_context<'a>(
|
||||
challenge_best: stats.map_or(0, |s| s.0.challenge_best_score),
|
||||
daily_today,
|
||||
draw_mode: settings
|
||||
.map(|s| s.0.draw_mode.clone())
|
||||
.map(|s| s.0.draw_mode)
|
||||
.unwrap_or(DrawMode::DrawOne),
|
||||
font_res,
|
||||
difficulty_expanded,
|
||||
@@ -589,6 +591,7 @@ fn handle_home_draw_mode_buttons(
|
||||
one_buttons: Query<&Interaction, (With<HomeDrawOneButton>, Changed<Interaction>)>,
|
||||
three_buttons: Query<&Interaction, (With<HomeDrawThreeButton>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||
mut settings: Option<ResMut<SettingsResource>>,
|
||||
storage_path: Option<Res<SettingsStoragePath>>,
|
||||
mut changed: MessageWriter<SettingsChangedEvent>,
|
||||
@@ -601,6 +604,12 @@ fn handle_home_draw_mode_buttons(
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
// Don't respawn while another modal sits on top — the despawn queues
|
||||
// immediately but executes at end of frame, so a respawn in the same
|
||||
// frame would create a second concurrent ModalScrim.
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
let want_one = one_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
let want_three = three_buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||
if !want_one && !want_three {
|
||||
@@ -658,6 +667,7 @@ fn handle_home_difficulty_toggle(
|
||||
mut commands: Commands,
|
||||
toggles: Query<&Interaction, (With<HomeDifficultyToggle>, Changed<Interaction>)>,
|
||||
screens: Query<Entity, With<HomeScreen>>,
|
||||
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
|
||||
mut diff_expanded: ResMut<DifficultyExpanded>,
|
||||
progress: Option<Res<ProgressResource>>,
|
||||
stats: Option<Res<StatsResource>>,
|
||||
@@ -668,6 +678,9 @@ fn handle_home_difficulty_toggle(
|
||||
if screens.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !other_modal_scrims.is_empty() {
|
||||
return;
|
||||
}
|
||||
if !toggles.iter().any(|i| *i == Interaction::Pressed) {
|
||||
return;
|
||||
}
|
||||
@@ -1103,7 +1116,7 @@ fn spawn_difficulty_section(parent: &mut ChildSpawnerCommands, ctx: &HomeContext
|
||||
let font_label = TextFont { font: font_handle.clone(), font_size: TYPE_BODY, ..default() };
|
||||
let font_chip = TextFont { font: font_handle, font_size: TYPE_CAPTION, ..default() };
|
||||
|
||||
let chevron = if ctx.difficulty_expanded { "▼" } else { "▶" };
|
||||
let chevron = if ctx.difficulty_expanded { "v" } else { ">" };
|
||||
|
||||
// Header row — click to toggle expand/collapse.
|
||||
parent
|
||||
@@ -1337,6 +1350,7 @@ fn spawn_mode_card(
|
||||
// bevy::ui — the click handler queries on `&Interaction`
|
||||
// which Button drives.
|
||||
Button,
|
||||
ModalButton(ButtonVariant::Secondary),
|
||||
Node {
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_2,
|
||||
|
||||
@@ -13,12 +13,14 @@ use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
use crate::auto_complete_plugin::AutoCompleteState;
|
||||
use crate::avatar_plugin::AvatarResource;
|
||||
use solitaire_data::SyncBackend;
|
||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||
use crate::progress_plugin::ProgressResource;
|
||||
use crate::settings_plugin::SettingsResource;
|
||||
use crate::layout::HUD_BAND_HEIGHT;
|
||||
use crate::safe_area::{SafeAreaAnchoredTop, SafeAreaInsets};
|
||||
use crate::safe_area::{SafeAreaAnchoredBottom, SafeAreaAnchoredTop};
|
||||
use crate::ui_theme::SPACE_2;
|
||||
use crate::ui_theme::{
|
||||
scaled_duration, ACCENT_PRIMARY, ACCENT_SECONDARY, BG_ELEVATED, BG_ELEVATED_HI,
|
||||
@@ -138,6 +140,13 @@ pub struct HudColumn;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HudActionBar;
|
||||
|
||||
/// 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.
|
||||
@@ -152,10 +161,13 @@ pub enum HudVisibility {
|
||||
#[derive(Resource, Debug, Default)]
|
||||
struct HudTapTracker {
|
||||
start_pos: Option<bevy::math::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 = 15.0;
|
||||
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
|
||||
@@ -286,6 +298,11 @@ pub struct HelpButton;
|
||||
#[derive(Component, Debug)]
|
||||
pub struct HintButton;
|
||||
|
||||
/// 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 = "!";
|
||||
|
||||
/// 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.
|
||||
@@ -354,6 +371,9 @@ pub enum MenuOption {
|
||||
/// 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.
|
||||
@@ -395,16 +415,23 @@ impl Plugin for HudPlugin {
|
||||
// WindowResized is registered by table_plugin; re-register
|
||||
// defensively so the HUD plugin works standalone in tests.
|
||||
.add_message::<WindowResized>()
|
||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons))
|
||||
.add_systems(Startup, (spawn_hud_band, spawn_hud, spawn_action_buttons, spawn_hud_avatar))
|
||||
.add_systems(Update, update_hud.after(GameMutation))
|
||||
.add_systems(
|
||||
Update,
|
||||
apply_hud_visibility.before(LayoutSystem::UpdateOnResize),
|
||||
)
|
||||
.add_systems(Update, restore_hud_on_modal)
|
||||
.add_systems(Update, (update_hud_avatar, handle_avatar_button))
|
||||
.add_systems(Update, update_won_previously.after(GameMutation))
|
||||
.add_systems(Update, announce_auto_complete.after(GameMutation))
|
||||
.add_systems(Update, update_selection_hud)
|
||||
.add_systems(
|
||||
Update,
|
||||
update_selection_hud.run_if(
|
||||
resource_exists_and_changed::<SelectionState>
|
||||
.or(resource_exists_and_changed::<GameStateResource>),
|
||||
),
|
||||
)
|
||||
.add_systems(Update, update_hud_typography)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -446,7 +473,12 @@ impl Plugin for HudPlugin {
|
||||
// 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.
|
||||
.add_systems(Last, (update_action_fade, apply_action_fade).chain());
|
||||
;
|
||||
// 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>()
|
||||
@@ -461,32 +493,24 @@ impl Plugin for HudPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the translucent HUD band that anchors the action buttons
|
||||
/// and primary readouts visually. Sits behind every other HUD element
|
||||
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
|
||||
/// without intercepting clicks from the buttons it sits under.
|
||||
/// 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.
|
||||
///
|
||||
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
|
||||
/// same constant the card layout reserves at the top), so the band's
|
||||
/// bottom edge lines up exactly with the top edge of the highest
|
||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||
/// alpha, so the green felt reads through subtly.
|
||||
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
||||
/// 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).
|
||||
fn spawn_hud_band(mut commands: Commands) {
|
||||
const BASE_TOP: f32 = 0.0;
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
commands.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(BASE_TOP + top_inset),
|
||||
top: Val::Px(BASE_TOP),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Px(HUD_BAND_HEIGHT),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_HUD_BAND),
|
||||
// Sit one z-rung below the HUD content so the buttons and text
|
||||
// paint on top, but above the card sprites (which are 2D-world
|
||||
// entities and rendered behind UI regardless).
|
||||
ZIndex(Z_HUD - 1),
|
||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||
HudBand,
|
||||
@@ -514,10 +538,8 @@ fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
||||
/// make Score the visual protagonist.
|
||||
fn spawn_hud(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let font_handle = font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default();
|
||||
let font_score = TextFont {
|
||||
font: font_handle.clone(),
|
||||
@@ -557,7 +579,7 @@ fn spawn_hud(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: VAL_SPACE_3,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
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
|
||||
@@ -684,6 +706,133 @@ fn spawn_hud(
|
||||
});
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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.
|
||||
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.
|
||||
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()) {
|
||||
// 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('?');
|
||||
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.
|
||||
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
|
||||
@@ -694,26 +843,20 @@ fn spawn_hud(
|
||||
/// on its own visual edge.
|
||||
fn spawn_action_buttons(
|
||||
font_res: Option<Res<FontResource>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let font = TextFont {
|
||||
font: font_res.as_ref().map(|f| f.0.clone()).unwrap_or_default(),
|
||||
font_size: TYPE_BODY,
|
||||
..default()
|
||||
};
|
||||
|
||||
// On Android, 7 text-labelled buttons at 48 dp each wrap to two rows on
|
||||
// a 411 dp phone. Use compact Unicode symbols and tighter gaps so all 7
|
||||
// fit in a single row (7×44 + 6×4 = 332 dp, well within a 90%-wide band
|
||||
// of 370 dp). On desktop, keep the descriptive text labels.
|
||||
// On Android, compact Unicode symbols fit all 7 buttons in one row.
|
||||
// On desktop, keep the descriptive text labels.
|
||||
#[cfg(target_os = "android")]
|
||||
let (max_width, col_gap, row_gap_val) =
|
||||
(Val::Percent(90.0), Val::Px(4.0), Val::Px(4.0));
|
||||
let col_gap = Val::Px(4.0);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let (max_width, col_gap, row_gap_val) =
|
||||
(Val::Percent(65.0), VAL_SPACE_2, VAL_SPACE_2);
|
||||
let col_gap = VAL_SPACE_2;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
let labels = (
|
||||
@@ -721,9 +864,8 @@ fn spawn_action_buttons(
|
||||
/* undo */ "\u{2190}", // ← leftwards arrow (Arrows block, confirmed FiraMono)
|
||||
/* pause */ "||", // || ASCII double-pipe — ‖ (U+2016) absent from FiraMono
|
||||
/* help */ "?",
|
||||
/* hint */ "\u{2192}", // → rightwards arrow (Arrows block, confirmed FiraMono)
|
||||
/* modes */ "\u{2193}", // ↓ downwards arrow (Arrows block, confirmed FiraMono)
|
||||
// replaces ▾ (U+25BE) which is absent from FiraMono
|
||||
/* hint */ ANDROID_HINT_LABEL,
|
||||
/* modes */ "M", // plain ASCII — U+21BB and U+21C4 both render as tofu on FiraMono
|
||||
/* new */ "+",
|
||||
);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
@@ -737,23 +879,33 @@ fn spawn_action_buttons(
|
||||
"New Game",
|
||||
);
|
||||
|
||||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
||||
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||
// Android reports it (frames 1-3); initial value is 0.0.
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
bottom: Val::Px(0.0),
|
||||
left: Val::Px(0.0),
|
||||
width: Val::Percent(100.0),
|
||||
flex_direction: FlexDirection::Row,
|
||||
max_width,
|
||||
flex_wrap: FlexWrap::Wrap,
|
||||
justify_content: JustifyContent::FlexEnd,
|
||||
justify_content: JustifyContent::Center,
|
||||
column_gap: col_gap,
|
||||
row_gap: row_gap_val,
|
||||
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),
|
||||
SafeAreaAnchoredTop { base_top: SPACE_2 },
|
||||
SafeAreaAnchoredBottom { base_bottom: 0.0 },
|
||||
HudActionBar,
|
||||
))
|
||||
.with_children(|row| {
|
||||
@@ -1012,6 +1164,14 @@ fn spawn_modes_popover(
|
||||
));
|
||||
}
|
||||
|
||||
// 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.
|
||||
#[cfg(target_os = "android")]
|
||||
let popover_bottom = Val::Px(200.0);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let popover_bottom = Val::Px(80.0);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
ModesPopover,
|
||||
@@ -1019,7 +1179,7 @@ fn spawn_modes_popover(
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
top: Val::Px(50.0),
|
||||
bottom: popover_bottom,
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
padding: UiRect::all(VAL_SPACE_2),
|
||||
@@ -1027,7 +1187,7 @@ fn spawn_modes_popover(
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
ZIndex(Z_HUD + 5),
|
||||
ZIndex(Z_HUD_POPOVER),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
for (option, label, tooltip) in rows {
|
||||
@@ -1054,8 +1214,8 @@ fn spawn_modes_popover(
|
||||
}
|
||||
});
|
||||
|
||||
// Fullscreen transparent backdrop at Z_HUD+4 (below the popover at
|
||||
// Z_HUD+5) so tapping outside the panel light-dismisses it.
|
||||
// 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,
|
||||
@@ -1068,7 +1228,7 @@ fn spawn_modes_popover(
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
ZIndex(Z_HUD + 4),
|
||||
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1204,6 +1364,12 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
),
|
||||
];
|
||||
|
||||
// Same upward-opening placement as ModesPopover.
|
||||
#[cfg(target_os = "android")]
|
||||
let popover_bottom = Val::Px(200.0);
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let popover_bottom = Val::Px(80.0);
|
||||
|
||||
commands
|
||||
.spawn((
|
||||
MenuPopover,
|
||||
@@ -1211,7 +1377,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
top: Val::Px(50.0),
|
||||
bottom: popover_bottom,
|
||||
flex_direction: FlexDirection::Column,
|
||||
row_gap: VAL_SPACE_1,
|
||||
padding: UiRect::all(VAL_SPACE_2),
|
||||
@@ -1219,7 +1385,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_ELEVATED),
|
||||
ZIndex(Z_HUD + 5),
|
||||
ZIndex(Z_HUD_POPOVER),
|
||||
))
|
||||
.with_children(|panel| {
|
||||
for (option, label, tooltip) in rows {
|
||||
@@ -1260,7 +1426,7 @@ fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&FontResource>)
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(Color::NONE),
|
||||
ZIndex(Z_HUD + 4),
|
||||
ZIndex(Z_HUD_POPOVER_BACKDROP),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1423,9 +1589,9 @@ impl Default for HudActionFade {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cursor-y threshold (in window pixels, 0 = top) below which the bar
|
||||
/// stays visible. Set slightly above `HUD_BAND_HEIGHT` so the bar fades
|
||||
/// in as the cursor approaches, not only once it crosses into the band.
|
||||
/// 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.
|
||||
const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||
|
||||
/// Lerp rate for fading (per second). 6.0 ≈ 167 ms for a full
|
||||
@@ -1434,7 +1600,7 @@ const ACTION_FADE_REVEAL_PX: f32 = HUD_BAND_HEIGHT + 32.0;
|
||||
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 (top of window) or off-screen
|
||||
/// 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.
|
||||
@@ -1446,8 +1612,9 @@ fn update_action_fade(
|
||||
let Ok(window) = windows.single() else {
|
||||
return;
|
||||
};
|
||||
let height = window.resolution.height();
|
||||
fade.target = match window.cursor_position() {
|
||||
Some(pos) if pos.y <= ACTION_FADE_REVEAL_PX => 1.0,
|
||||
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.
|
||||
@@ -1588,6 +1755,11 @@ fn detect_score_change(
|
||||
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)
|
||||
@@ -1633,7 +1805,7 @@ fn detect_score_change(
|
||||
top: Val::Px(0.0),
|
||||
..default()
|
||||
},
|
||||
ZIndex(Z_HUD + 10),
|
||||
ZIndex(Z_HUD_TOP),
|
||||
Text::new(format!("+{delta}")),
|
||||
font,
|
||||
TextColor(ACCENT_PRIMARY),
|
||||
@@ -1761,6 +1933,9 @@ fn start_streak_flourish(
|
||||
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)
|
||||
@@ -2280,15 +2455,9 @@ fn update_hud_typography(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn apply_hud_visibility(
|
||||
hud_vis: Res<HudVisibility>,
|
||||
mut nodes: Query<
|
||||
&mut Visibility,
|
||||
Or<(With<HudBand>, With<HudColumn>, With<HudActionBar>)>,
|
||||
>,
|
||||
window_entities: Query<(Entity, &Window)>,
|
||||
mut resize_events: MessageWriter<WindowResized>,
|
||||
mut action_bar: Query<&mut Visibility, With<HudActionBar>>,
|
||||
) {
|
||||
if !hud_vis.is_changed() {
|
||||
return;
|
||||
@@ -2298,16 +2467,11 @@ fn apply_hud_visibility(
|
||||
} else {
|
||||
Visibility::Hidden
|
||||
};
|
||||
for mut node_vis in &mut nodes {
|
||||
*node_vis = v;
|
||||
}
|
||||
if let Some((entity, window)) = window_entities.iter().next() {
|
||||
resize_events.write(WindowResized {
|
||||
window: entity,
|
||||
width: window.resolution.width(),
|
||||
height: window.resolution.height(),
|
||||
});
|
||||
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.
|
||||
}
|
||||
|
||||
fn restore_hud_on_modal(
|
||||
@@ -2327,29 +2491,47 @@ fn toggle_hud_on_tap(
|
||||
paused: Option<Res<PausedResource>>,
|
||||
mut tracker: ResMut<HudTapTracker>,
|
||||
mut hud_vis: ResMut<HudVisibility>,
|
||||
buttons: Query<&Interaction, With<ActionButton>>,
|
||||
) {
|
||||
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;
|
||||
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() => {
|
||||
let on_button = tracker.started_on_button;
|
||||
if let Some(start) = tracker.start_pos.take() {
|
||||
if (event.position - start).length() < HUD_TAP_SLOP_PX {
|
||||
if !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;
|
||||
}
|
||||
TouchPhase::Canceled | TouchPhase::Moved => {
|
||||
// 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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -2837,6 +3019,35 @@ mod tests {
|
||||
assert!((streak_flourish_scale(0.5, 0.0) - 1.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Reduce-motion gates — ScorePulse, ScoreFloater, StreakFlourish
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
/// Under `Settings::reduce_motion_mode`, a score bump must NOT spawn
|
||||
/// a `ScorePulse` on the readout or a `ScoreFloater` on the stage.
|
||||
#[test]
|
||||
fn score_change_skips_pulse_and_floater_under_reduce_motion() {
|
||||
use solitaire_data::Settings;
|
||||
let mut app = headless_app();
|
||||
app.insert_resource(SettingsResource(Settings {
|
||||
reduce_motion_mode: true,
|
||||
..Settings::default()
|
||||
}));
|
||||
// +100 would normally create both a ScorePulse and a ScoreFloater.
|
||||
app.world_mut().resource_mut::<GameStateResource>().0.score = 100;
|
||||
app.update();
|
||||
assert_eq!(
|
||||
count_with::<ScorePulse>(&mut app),
|
||||
0,
|
||||
"ScorePulse must not spawn under reduce-motion"
|
||||
);
|
||||
assert_eq!(
|
||||
count_with::<ScoreFloater>(&mut app),
|
||||
0,
|
||||
"ScoreFloater must not spawn under reduce-motion"
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Phase 2: keyboard focus ring — HUD action bar
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
@@ -717,13 +717,12 @@ fn end_drag(
|
||||
let ok = match &target {
|
||||
PileType::Foundation(_) => {
|
||||
count == 1
|
||||
&& can_place_on_foundation(
|
||||
&bottom_card,
|
||||
&game.0.piles[&target],
|
||||
)
|
||||
&& game.0.piles.get(&target)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
||||
game.0.piles.get(&target)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
@@ -941,10 +940,10 @@ fn touch_end_drag(
|
||||
continue;
|
||||
}
|
||||
|
||||
// Uncommitted tap — cancel cleanly.
|
||||
// Uncommitted tap — cancel cleanly. No StateChangedEvent: nothing
|
||||
// changed. The mouse path (end_drag) follows the same convention.
|
||||
if !drag.committed {
|
||||
drag.clear();
|
||||
changed.write(StateChangedEvent);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -972,10 +971,12 @@ fn touch_end_drag(
|
||||
let ok = match &target {
|
||||
PileType::Foundation(_) => {
|
||||
count == 1
|
||||
&& can_place_on_foundation(&bottom_card, &game.0.piles[&target])
|
||||
&& game.0.piles.get(&target)
|
||||
.is_some_and(|p| can_place_on_foundation(&bottom_card, p))
|
||||
}
|
||||
PileType::Tableau(_) => {
|
||||
can_place_on_tableau(&bottom_card, &game.0.piles[&target])
|
||||
game.0.piles.get(&target)
|
||||
.is_some_and(|p| can_place_on_tableau(&bottom_card, p))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
@@ -1161,7 +1162,9 @@ fn find_draggable_at(
|
||||
(i, pile_cards.cards.len())
|
||||
} else {
|
||||
if i != pile_cards.cards.len() - 1 {
|
||||
return None;
|
||||
// Non-top card on a non-tableau pile — not draggable; skip
|
||||
// this pile and continue searching remaining piles.
|
||||
break;
|
||||
}
|
||||
(i, i + 1)
|
||||
};
|
||||
@@ -1785,9 +1788,8 @@ mod tests {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Tableau 6 has 7 cards.
|
||||
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
||||
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
|
||||
// size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5.
|
||||
let expected = layout.card_size.y * 2.5;
|
||||
// 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 {}",
|
||||
|
||||
+105
-20
@@ -48,6 +48,24 @@ pub const MIN_WINDOW: Vec2 = Vec2::new(320.0, 400.0);
|
||||
/// which rendered the cards ~3.6 % squashed vertically.
|
||||
const CARD_ASPECT: f32 = 1.4523;
|
||||
|
||||
/// Divisor used to derive the horizontal gap between columns from the card
|
||||
/// width: `h_gap = card_width / H_GAP_DIVISOR`.
|
||||
///
|
||||
/// This constant also drives `card_width_width_based`:
|
||||
/// total layout width = 7*card_width + 8*h_gap = card_width*(7 + 8/H_GAP_DIVISOR)
|
||||
/// → card_width = window.x / (7 + 8/H_GAP_DIVISOR)
|
||||
///
|
||||
/// Desktop (H_GAP_DIVISOR = 4): card_width = window.x / 9 — existing behaviour.
|
||||
/// Android (H_GAP_DIVISOR = 32): card_width = window.x / 7.25 — cards are ~10 %
|
||||
/// wider than at divisor 8, with very tight gaps (~4 px) that are still visible
|
||||
/// as a faint seam between columns. The primary readability boost on Android
|
||||
/// comes from the `AndroidCornerLabel` overlay in `card_plugin`, but maximising
|
||||
/// the physical card size helps too.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const H_GAP_DIVISOR: f32 = 4.0;
|
||||
#[cfg(target_os = "android")]
|
||||
const H_GAP_DIVISOR: f32 = 32.0;
|
||||
|
||||
/// Fraction of card height used as vertical padding between the top row and
|
||||
/// the tableau row.
|
||||
const VERTICAL_GAP_FRAC: f32 = 0.2;
|
||||
@@ -57,7 +75,7 @@ const VERTICAL_GAP_FRAC: f32 = 0.2;
|
||||
/// column must fit at this fraction). On desktop (height-limited) windows the
|
||||
/// adaptive computation returns this value exactly; on portrait phones it
|
||||
/// expands to fill available vertical space.
|
||||
const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
const TABLEAU_FAN_FRAC: f32 = 0.18;
|
||||
|
||||
/// Minimum fraction for face-down tableau cards. Scales proportionally with
|
||||
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
|
||||
@@ -66,7 +84,7 @@ const TABLEAU_FAN_FRAC: f32 = 0.25;
|
||||
/// enough of each card back to read as a meaningful stack rather than a
|
||||
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
|
||||
/// the adaptive scaling in `compute_layout`.
|
||||
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.20;
|
||||
const TABLEAU_FACEDOWN_FAN_FRAC: f32 = 0.14;
|
||||
|
||||
/// Largest possible face-up tableau column in Klondike: a King down to an Ace
|
||||
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
||||
@@ -77,15 +95,14 @@ const MAX_TABLEAU_CARDS: f32 = 13.0;
|
||||
/// (action buttons, Score / Moves / Timer readouts). The card grid starts
|
||||
/// below this band so the HUD doesn't bleed into the play surface.
|
||||
///
|
||||
/// Desktop: 64 px fits the single-row action bar plus the Score/Moves line.
|
||||
/// Android: 128 px accommodates the two-row button wrap on narrow phones
|
||||
/// (7 buttons × ~52 dp each, with a 65% max-width constraint, wraps to two
|
||||
/// ~48 dp rows plus row-gap). Without this larger reserve the bottom row of
|
||||
/// buttons overlaps the top card row.
|
||||
/// Desktop: 64 px fits the score/moves/time + mode badge rows.
|
||||
/// Android: 80 px gives the same content rows comfortable clearance.
|
||||
/// (Previously 128 px when action buttons lived in the top band; those are
|
||||
/// now in the bottom bar so the larger reserve is no longer needed.)
|
||||
#[cfg(not(target_os = "android"))]
|
||||
pub const HUD_BAND_HEIGHT: f32 = 64.0;
|
||||
#[cfg(target_os = "android")]
|
||||
pub const HUD_BAND_HEIGHT: f32 = 128.0;
|
||||
pub const HUD_BAND_HEIGHT: f32 = 80.0;
|
||||
|
||||
/// Table background colour (dark green felt).
|
||||
pub const TABLE_COLOUR: [f32; 3] = [0.059, 0.322, 0.196];
|
||||
@@ -150,8 +167,10 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
||||
let window = window.max(MIN_WINDOW);
|
||||
let band_h = if hud_visible { HUD_BAND_HEIGHT } else { 0.0 };
|
||||
|
||||
// Width-based candidate (existing behaviour): 7 cards + 8 h_gaps = 9*card_width.
|
||||
let card_width_width_based = window.x / 9.0;
|
||||
// Width-based candidate: 7 cards + 8 h_gaps where h_gap = card_width/H_GAP_DIVISOR.
|
||||
// Total = card_width*(7 + 8/H_GAP_DIVISOR) = window.x → card_width = window.x/card_width_divisor.
|
||||
let card_width_divisor = 7.0 + 8.0 / H_GAP_DIVISOR;
|
||||
let card_width_width_based = window.x / card_width_divisor;
|
||||
|
||||
// Height-based candidate. The vertical budget below the top row must hold
|
||||
// a worst-case fanned tableau column plus a bottom margin equal to h_gap.
|
||||
@@ -176,13 +195,12 @@ pub fn compute_layout(window: Vec2, safe_area_top: f32, safe_area_bottom: f32, h
|
||||
let card_height = card_width * CARD_ASPECT;
|
||||
let card_size = Vec2::new(card_width, card_height);
|
||||
|
||||
let h_gap = card_width / 4.0;
|
||||
// Total occupied width = 7*card_width + 8*h_gap = 9*card_width. When card
|
||||
// sizing is height-limited (tall/narrow windows), this is smaller than
|
||||
// window.x, so the grid is centred horizontally; otherwise side_margin
|
||||
// collapses to h_gap and the geometry matches the original width-based
|
||||
// layout exactly.
|
||||
let total_grid_width = 9.0 * card_width;
|
||||
let h_gap = card_width / H_GAP_DIVISOR;
|
||||
// Total occupied width = 7*card_width + 8*h_gap = card_width_divisor*card_width.
|
||||
// When card sizing is height-limited (tall/narrow windows) this is smaller than
|
||||
// window.x and the grid is centred horizontally; otherwise side_margin collapses
|
||||
// to h_gap and the geometry fills the window exactly.
|
||||
let total_grid_width = card_width_divisor * card_width;
|
||||
let side_margin = (window.x - total_grid_width) / 2.0 + h_gap;
|
||||
let left_edge = -window.x / 2.0;
|
||||
let col_x = |col: usize| -> f32 {
|
||||
@@ -402,11 +420,10 @@ mod tests {
|
||||
#[test]
|
||||
fn tall_narrow_window_keeps_width_based_sizing() {
|
||||
// Tall narrow window: there's plenty of vertical budget, so width is
|
||||
// the bottleneck and card_width matches the legacy window.x / 9
|
||||
// derivation exactly.
|
||||
// the bottleneck and card_width matches window.x / (7 + 8/H_GAP_DIVISOR).
|
||||
let window = Vec2::new(900.0, 1600.0);
|
||||
let layout = compute_layout(window, 0.0, 0.0, true);
|
||||
let width_based = window.x / 9.0;
|
||||
let width_based = window.x / (7.0 + 8.0 / H_GAP_DIVISOR);
|
||||
assert!(
|
||||
(layout.card_size.x - width_based).abs() < 1e-3,
|
||||
"expected width-based sizing (card_width {} should equal {})",
|
||||
@@ -588,6 +605,74 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Suspend → resume layout-consistency invariant.
|
||||
///
|
||||
/// If the resume handler resets `SafeAreaInsets` to zero and then the JNI
|
||||
/// poller re-resolves the same values, `compute_layout` must produce an
|
||||
/// identical result to the fresh-launch layout. This test also verifies
|
||||
/// that a layout computed with `safe_area_top = 0` (the brief window while
|
||||
/// insets haven't re-resolved after resume) differs visibly from the
|
||||
/// correct layout, confirming that the bug would manifest without the fix.
|
||||
#[test]
|
||||
fn suspend_resume_layout_matches_fresh_launch() {
|
||||
let window = Vec2::new(900.0, 2000.0);
|
||||
let safe_top = 27.0_f32;
|
||||
let safe_bottom = 110.0_f32;
|
||||
|
||||
// Fresh-launch layout — insets known from startup.
|
||||
let fresh = compute_layout(window, safe_top, safe_bottom, true);
|
||||
|
||||
// Layout computed during the brief post-resume window before insets
|
||||
// re-resolve (safe_area_top temporarily 0).
|
||||
let wrong = compute_layout(window, 0.0, safe_bottom, true);
|
||||
|
||||
// Verify the "wrong" layout actually differs — the bug would push the
|
||||
// top card row upward by exactly safe_top pixels.
|
||||
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
|
||||
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
|
||||
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
|
||||
// downward (−y direction). So wrong_stock_y > fresh_stock_y by safe_top.
|
||||
assert!(
|
||||
(wrong_stock_y - fresh_stock_y - safe_top).abs() < 1e-3,
|
||||
"wrong layout must displace stock upward by safe_top ({safe_top}): \
|
||||
fresh={fresh_stock_y:.2} wrong={wrong_stock_y:.2} delta={:.2}",
|
||||
wrong_stock_y - fresh_stock_y,
|
||||
);
|
||||
|
||||
// After the poller re-resolves correct insets the layout must be
|
||||
// identical to the fresh-launch layout.
|
||||
let corrected = compute_layout(window, safe_top, safe_bottom, true);
|
||||
assert_eq!(
|
||||
corrected.card_size, fresh.card_size,
|
||||
"card size must be preserved after resume",
|
||||
);
|
||||
assert!(
|
||||
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
|
||||
"stock y must match fresh launch after resume: \
|
||||
corrected={:.2} fresh={fresh_stock_y:.2}",
|
||||
corrected.pile_positions[&PileType::Stock].y,
|
||||
);
|
||||
assert!(
|
||||
(corrected.pile_positions[&PileType::Stock].x
|
||||
- fresh.pile_positions[&PileType::Stock].x)
|
||||
.abs()
|
||||
< 1e-3,
|
||||
"stock x must be unchanged after resume",
|
||||
);
|
||||
// The HUD band top clearance (distance from window top to card top)
|
||||
// must match as well — this is the quantity directly visible in Bug 2.
|
||||
let card_top = |layout: &super::Layout| {
|
||||
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
|
||||
};
|
||||
assert!(
|
||||
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
|
||||
"top-of-card must match fresh launch after resume: \
|
||||
corrected={:.2} fresh={:.2}",
|
||||
card_top(&corrected),
|
||||
card_top(&fresh),
|
||||
);
|
||||
}
|
||||
|
||||
/// safe_area_bottom must not affect horizontal positions.
|
||||
#[test]
|
||||
fn safe_area_bottom_does_not_affect_horizontal_layout() {
|
||||
|
||||
@@ -350,7 +350,7 @@ fn handle_opt_in_button(
|
||||
None
|
||||
}
|
||||
})
|
||||
.map(str::to_string)
|
||||
.map(|n| n.chars().take(32).collect::<String>())
|
||||
})
|
||||
.unwrap_or_else(|| "Player".to_string());
|
||||
|
||||
@@ -375,7 +375,7 @@ fn poll_opt_in_task(
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard opt-in failed: {e}");
|
||||
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
|
||||
toast.write(InfoToastEvent("Failed to join leaderboard".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -415,7 +415,7 @@ fn poll_opt_out_task(
|
||||
}
|
||||
Err(e) => {
|
||||
warn!("leaderboard opt-out failed: {e}");
|
||||
toast.write(InfoToastEvent("Leaderboard update failed".to_string()));
|
||||
toast.write(InfoToastEvent("Failed to leave leaderboard".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -746,7 +746,7 @@ fn handle_display_name_confirm(
|
||||
return;
|
||||
}
|
||||
if let Some(mut settings) = settings {
|
||||
let trimmed = buf.0.trim().to_string();
|
||||
let trimmed: String = buf.0.trim().chars().take(32).collect();
|
||||
settings.0.leaderboard_display_name = if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
|
||||
@@ -235,7 +235,7 @@ fn toggle_pause(
|
||||
// Snapshot current level and streak at pause time.
|
||||
let level = progress.as_deref().map(|p| p.0.level);
|
||||
let streak = stats.as_deref().map(|s| s.0.win_streak_current);
|
||||
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode.clone());
|
||||
let draw_mode = settings.as_deref().map(|s| s.0.draw_mode);
|
||||
spawn_pause_screen(
|
||||
&mut commands,
|
||||
level,
|
||||
@@ -437,10 +437,15 @@ fn close_forfeit_modal(
|
||||
/// The player reaches these overlays via the HUD menu while paused, which
|
||||
/// causes both the pause modal and the overlay to be live simultaneously.
|
||||
/// That is always unintentional — the overlay should own the screen.
|
||||
/// Query filter for modals that are not part of the pause flow.
|
||||
/// Excludes both `PauseScreen` (the pause modal itself) and
|
||||
/// `ForfeitConfirmScreen` (spawned from within the pause flow).
|
||||
type NonPauseFamilyScrim = (With<ModalScrim>, Without<PauseScreen>, Without<ForfeitConfirmScreen>);
|
||||
|
||||
fn auto_resume_on_overlay(
|
||||
mut commands: Commands,
|
||||
pause_screens: Query<Entity, With<PauseScreen>>,
|
||||
other_modal_scrims: Query<Entity, (With<ModalScrim>, Without<PauseScreen>)>,
|
||||
other_modal_scrims: Query<Entity, NonPauseFamilyScrim>,
|
||||
mut paused: ResMut<PausedResource>,
|
||||
) {
|
||||
if pause_screens.is_empty() || other_modal_scrims.is_empty() {
|
||||
@@ -449,7 +454,9 @@ fn auto_resume_on_overlay(
|
||||
for entity in &pause_screens {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
if paused.0 {
|
||||
paused.0 = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the pause modal using the standard `ui_modal` scaffold —
|
||||
|
||||
@@ -338,7 +338,7 @@ fn tick_debounce_and_spawn_solver_task(
|
||||
|
||||
let draw_mode = settings
|
||||
.as_ref()
|
||||
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode.clone());
|
||||
.map_or(DrawMode::DrawOne, |s| s.0.draw_mode);
|
||||
let cfg = SolverConfig::default();
|
||||
let task = AsyncComputeTaskPool::get()
|
||||
.spawn(async move { try_solve(seed, draw_mode, &cfg) });
|
||||
|
||||
@@ -33,8 +33,11 @@ use crate::replay_playback::{
|
||||
step_backwards_replay_playback, step_replay_playback, stop_replay_playback,
|
||||
toggle_pause_replay_playback, ReplayPlaybackState,
|
||||
};
|
||||
use solitaire_core::card::{Card, Rank, Suit};
|
||||
use solitaire_core::game_state::GameState;
|
||||
use solitaire_core::pile::PileType;
|
||||
use solitaire_data::ReplayMove;
|
||||
use crate::resources::GameStateResource;
|
||||
use crate::ui_modal::{spawn_modal_button, ButtonVariant};
|
||||
use crate::ui_theme::{
|
||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBackground, HighContrastBorder,
|
||||
@@ -154,6 +157,12 @@ const MOVE_LOG_PREV_ROWS: usize = 2;
|
||||
/// preview-shape might need rethinking.
|
||||
const MOVE_LOG_NEXT_ROWS: usize = 2;
|
||||
|
||||
/// Vertical offset from the top edge of the window to the top edge of the
|
||||
/// mini-tableau preview panel. Places the panel 8 px below the banner's
|
||||
/// bottom edge so the two surfaces don't overlap. Derived from
|
||||
/// `BANNER_HEIGHT` so the gap stays consistent if the banner ever grows.
|
||||
const MINI_TABLEAU_TOP_OFFSET: f32 = BANNER_HEIGHT + 8.0;
|
||||
|
||||
/// Background colour alpha for the banner. `BG_ELEVATED_HI` at this alpha
|
||||
/// reads as a clear "this is a UI strip" callout while still letting the
|
||||
/// felt show through enough to anchor the banner to the play surface.
|
||||
@@ -404,6 +413,34 @@ pub struct ReplayOverlayMoveLogNextRow {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -451,6 +488,8 @@ impl Plugin for ReplayOverlayPlugin {
|
||||
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,
|
||||
@@ -476,10 +515,8 @@ impl Plugin for ReplayOverlayPlugin {
|
||||
fn react_to_state_change(
|
||||
mut commands: Commands,
|
||||
state: Res<ReplayPlaybackState>,
|
||||
existing: Query<Entity, With<ReplayOverlayRoot>>,
|
||||
floating_chips: Query<Entity, With<ReplayFloatingProgressChip>>,
|
||||
move_log_panels: Query<Entity, With<ReplayOverlayMoveLogPanel>>,
|
||||
dim_layers: Query<Entity, With<ReplayTableauDimLayer>>,
|
||||
roots: Query<Entity, With<ReplayOverlayRoot>>,
|
||||
despawnable: Query<Entity, With<DespawnWithReplay>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if !state.is_changed() {
|
||||
@@ -487,30 +524,15 @@ fn react_to_state_change(
|
||||
}
|
||||
|
||||
let should_be_visible = state.is_playing() || state.is_completed();
|
||||
let already_spawned = existing.iter().next().is_some();
|
||||
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 {
|
||||
for entity in &existing {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
// Floating chip lives outside the UI tree (world-space
|
||||
// entity), so the banner-root despawn doesn't reach it.
|
||||
// Despawn separately on the same state transition so both
|
||||
// disappear together when the replay ends.
|
||||
for entity in &floating_chips {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
// Move-log panel is also a separate root entity (sibling
|
||||
// of the banner anchored to the viewport's bottom edge),
|
||||
// so the banner-root despawn doesn't reach it either.
|
||||
for entity in &move_log_panels {
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
// Tableau dim layer is also a separate root entity — same
|
||||
// pattern as the move-log panel.
|
||||
for entity in &dim_layers {
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
@@ -546,6 +568,8 @@ fn spawn_overlay(
|
||||
// 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.
|
||||
@@ -562,6 +586,7 @@ fn spawn_overlay(
|
||||
// component — purely visual.
|
||||
commands.spawn((
|
||||
ReplayTableauDimLayer,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
@@ -585,6 +610,7 @@ fn spawn_overlay(
|
||||
commands
|
||||
.spawn((
|
||||
ReplayOverlayRoot,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
@@ -967,6 +993,7 @@ fn spawn_overlay(
|
||||
// when the replay state transitions back to `Inactive`.
|
||||
commands.spawn((
|
||||
ReplayFloatingProgressChip,
|
||||
DespawnWithReplay,
|
||||
Text2d::new(format_progress(state)),
|
||||
TextFont {
|
||||
font: font_handle_for_floating,
|
||||
@@ -996,6 +1023,7 @@ fn spawn_overlay(
|
||||
commands
|
||||
.spawn((
|
||||
ReplayOverlayMoveLogPanel,
|
||||
DespawnWithReplay,
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
left: Val::Px(0.0),
|
||||
@@ -1111,6 +1139,68 @@ fn spawn_overlay(
|
||||
));
|
||||
}
|
||||
});
|
||||
|
||||
// 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
|
||||
@@ -1554,6 +1644,118 @@ fn format_active_move_row(state: &ReplayPlaybackState) -> String {
|
||||
format!("\u{25B6} {body}") // ▶
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mini-tableau format helpers and update system
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pure helper — short rank symbol. Single character for all ranks
|
||||
/// except Ten which uses "T" (keeps every card a consistent 2-char
|
||||
/// wide render: rank-char + suit-glyph). Players familiar with
|
||||
/// solitaire shorthand read "T" instantly; the suit glyph immediately
|
||||
/// follows and disambiguates from an ambiguous "T".
|
||||
fn format_rank_short(rank: Rank) -> &'static str {
|
||||
match 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 => "T",
|
||||
Rank::Jack => "J",
|
||||
Rank::Queen => "Q",
|
||||
Rank::King => "K",
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — Unicode suit glyph from FiraMono's covered range
|
||||
/// (U+2660–U+2666). These four code points are confirmed present in
|
||||
/// the bundled FiraMono on Android (verified on Pixel 7 / API 34).
|
||||
fn format_suit_glyph(suit: Suit) -> &'static str {
|
||||
match suit {
|
||||
Suit::Spades => "\u{2660}", // ♠
|
||||
Suit::Hearts => "\u{2665}", // ♥
|
||||
Suit::Diamonds => "\u{2666}", // ♦
|
||||
Suit::Clubs => "\u{2663}", // ♣
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — compact 2-char card label (`rank + suit glyph`) for a
|
||||
/// known card, or `"--"` for an absent top card (empty pile).
|
||||
fn format_card_short(card: Option<&Card>) -> String {
|
||||
match card {
|
||||
Some(c) => format!("{}{}", format_rank_short(c.rank), format_suit_glyph(c.suit)),
|
||||
None => "--".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure helper — one-line summary of the four foundation tops.
|
||||
/// Renders as `F: A♠ 7♥ 5♦ K♣` with `--` for any empty slot.
|
||||
/// Foundation slots are displayed in their natural 0-3 order
|
||||
/// (matching the visual left-to-right order on screen).
|
||||
fn format_foundations_row(game: &GameState) -> String {
|
||||
let slots: [String; 4] = std::array::from_fn(|i| {
|
||||
let top = game.piles
|
||||
.get(&PileType::Foundation(i as u8))
|
||||
.and_then(|p| p.cards.last());
|
||||
format_card_short(top)
|
||||
});
|
||||
format!("F: {} {} {} {}", slots[0], slots[1], slots[2], slots[3])
|
||||
}
|
||||
|
||||
/// Pure helper — one-line stock / waste summary.
|
||||
/// Renders as `STK:N WST:X♠` where N is the stock card count and
|
||||
/// X♠ is the top waste card (or `--` when the waste pile is empty).
|
||||
fn format_stock_waste_row(game: &GameState) -> String {
|
||||
let stock_count = game.piles
|
||||
.get(&PileType::Stock)
|
||||
.map(|p| p.cards.len())
|
||||
.unwrap_or(0);
|
||||
let waste_top = game.piles
|
||||
.get(&PileType::Waste)
|
||||
.and_then(|p| p.cards.last());
|
||||
format!("STK:{} WST:{}", stock_count, format_card_short(waste_top))
|
||||
}
|
||||
|
||||
/// Repaints the foundations row whenever [`GameStateResource`] changes.
|
||||
/// Split into its own system (rather than combined with the stock/waste
|
||||
/// updater) to avoid a Bevy B0001 query conflict: two `&mut Text`
|
||||
/// queries in one system are always ambiguous regardless of marker
|
||||
/// filters. Each updater owns exactly one `Query<&mut Text, With<…>>`.
|
||||
fn update_mini_tableau_foundations(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<ReplayMiniTableauFoundations>>,
|
||||
) {
|
||||
let Some(game) = game else { return };
|
||||
if !game.is_changed() {
|
||||
return;
|
||||
}
|
||||
let text = format_foundations_row(&game.0);
|
||||
for mut t in &mut q {
|
||||
**t = text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// Repaints the stock/waste row whenever [`GameStateResource`] changes.
|
||||
/// Sibling of [`update_mini_tableau_foundations`] — same change-detection
|
||||
/// guard, separate system to avoid the B0001 query conflict.
|
||||
fn update_mini_tableau_stock_waste(
|
||||
game: Option<Res<GameStateResource>>,
|
||||
mut q: Query<&mut Text, With<ReplayMiniTableauStockWaste>>,
|
||||
) {
|
||||
let Some(game) = game else { return };
|
||||
if !game.is_changed() {
|
||||
return;
|
||||
}
|
||||
let text = format_stock_waste_row(&game.0);
|
||||
for mut t in &mut q {
|
||||
**t = text.clone();
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Playback-control button handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1763,6 +1965,7 @@ fn handle_stop_keyboard(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
use solitaire_core::card::{Rank, Suit};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_data::{Replay, ReplayMove};
|
||||
|
||||
@@ -3990,4 +4193,113 @@ mod tests {
|
||||
fn dim_layer_z_is_below_replay_chrome() {
|
||||
const { assert!(Z_REPLAY_DIM < Z_REPLAY_OVERLAY) }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Mini-tableau preview tests
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn mini_tableau_panel_count(app: &mut App) -> usize {
|
||||
app.world_mut()
|
||||
.query::<&ReplayMiniTableauPanel>()
|
||||
.iter(app.world())
|
||||
.count()
|
||||
}
|
||||
|
||||
/// Mini-tableau panel spawns alongside the other overlay surfaces
|
||||
/// when playback starts and despawns when it ends.
|
||||
#[test]
|
||||
fn mini_tableau_panel_spawns_and_despawns_with_overlay() {
|
||||
let mut app = headless_app();
|
||||
|
||||
app.update();
|
||||
assert_eq!(
|
||||
mini_tableau_panel_count(&mut app),
|
||||
0,
|
||||
"no mini-tableau panel while playback is Inactive",
|
||||
);
|
||||
|
||||
set_state(
|
||||
&mut app,
|
||||
ReplayPlaybackState::Playing {
|
||||
replay: synthetic_replay(5),
|
||||
cursor: 0,
|
||||
secs_to_next: 0.5,
|
||||
paused: false,
|
||||
},
|
||||
);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
mini_tableau_panel_count(&mut app),
|
||||
1,
|
||||
"mini-tableau panel must spawn when playback starts",
|
||||
);
|
||||
|
||||
set_state(&mut app, ReplayPlaybackState::Inactive);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
mini_tableau_panel_count(&mut app),
|
||||
0,
|
||||
"mini-tableau panel must despawn when playback ends",
|
||||
);
|
||||
}
|
||||
|
||||
/// `format_rank_short` maps every `Rank` variant to a single ASCII
|
||||
/// character except Ten which maps to `"T"`.
|
||||
#[test]
|
||||
fn format_rank_short_all_ranks() {
|
||||
assert_eq!(format_rank_short(Rank::Ace), "A");
|
||||
assert_eq!(format_rank_short(Rank::Two), "2");
|
||||
assert_eq!(format_rank_short(Rank::Three), "3");
|
||||
assert_eq!(format_rank_short(Rank::Four), "4");
|
||||
assert_eq!(format_rank_short(Rank::Five), "5");
|
||||
assert_eq!(format_rank_short(Rank::Six), "6");
|
||||
assert_eq!(format_rank_short(Rank::Seven), "7");
|
||||
assert_eq!(format_rank_short(Rank::Eight), "8");
|
||||
assert_eq!(format_rank_short(Rank::Nine), "9");
|
||||
assert_eq!(format_rank_short(Rank::Ten), "T");
|
||||
assert_eq!(format_rank_short(Rank::Jack), "J");
|
||||
assert_eq!(format_rank_short(Rank::Queen), "Q");
|
||||
assert_eq!(format_rank_short(Rank::King), "K");
|
||||
}
|
||||
|
||||
/// `format_suit_glyph` returns the FiraMono-covered Unicode suit
|
||||
/// glyphs for each `Suit` variant (U+2660–U+2666 confirmed on Android).
|
||||
#[test]
|
||||
fn format_suit_glyph_all_suits() {
|
||||
assert_eq!(format_suit_glyph(Suit::Spades), "\u{2660}");
|
||||
assert_eq!(format_suit_glyph(Suit::Hearts), "\u{2665}");
|
||||
assert_eq!(format_suit_glyph(Suit::Diamonds), "\u{2666}");
|
||||
assert_eq!(format_suit_glyph(Suit::Clubs), "\u{2663}");
|
||||
}
|
||||
|
||||
/// `format_foundations_row` with a freshly-dealt game (all empty).
|
||||
#[test]
|
||||
fn format_foundations_row_empty_board() {
|
||||
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||
42,
|
||||
solitaire_core::game_state::DrawMode::DrawOne,
|
||||
solitaire_core::game_state::GameMode::Classic,
|
||||
);
|
||||
assert_eq!(format_foundations_row(&game), "F: -- -- -- --");
|
||||
}
|
||||
|
||||
/// `format_stock_waste_row` with a freshly-dealt game: stock has
|
||||
/// 24 cards, waste is empty.
|
||||
#[test]
|
||||
fn format_stock_waste_row_initial_state() {
|
||||
let game = solitaire_core::game_state::GameState::new_with_mode(
|
||||
42,
|
||||
solitaire_core::game_state::DrawMode::DrawOne,
|
||||
solitaire_core::game_state::GameMode::Classic,
|
||||
);
|
||||
let text = format_stock_waste_row(&game);
|
||||
assert!(
|
||||
text.starts_with("STK:"),
|
||||
"row must start with STK: prefix; got {text:?}",
|
||||
);
|
||||
assert!(
|
||||
text.contains("WST:--"),
|
||||
"waste must show -- on a fresh deal; got {text:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ pub fn start_replay_playback(
|
||||
) {
|
||||
use solitaire_core::game_state::GameState;
|
||||
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||
let fresh = GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||
commands.insert_resource(GameStateResource(fresh));
|
||||
|
||||
// Initial `secs_to_next` uses the constant rather than reading
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
//! Bevy resources owned by the engine crate.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use bevy::math::Vec2;
|
||||
use bevy::prelude::Resource;
|
||||
use chrono::{DateTime, Utc};
|
||||
@@ -111,3 +113,26 @@ pub struct HintCycleIndex(pub usize);
|
||||
/// returns to the same position in the list without re-scrolling.
|
||||
#[derive(Resource, Debug, Clone, Default)]
|
||||
pub struct SettingsScrollPos(pub f32);
|
||||
|
||||
/// Shared Tokio runtime used by all async-task closures that need HTTP I/O.
|
||||
///
|
||||
/// Bevy's `AsyncComputeTaskPool` uses `async-executor` (not Tokio), so spawned
|
||||
/// closures that call `reqwest`/`hyper` need a Tokio reactor. A single
|
||||
/// multi-threaded runtime is built once at startup and its `Arc` cloned cheaply
|
||||
/// into every network task — safe for concurrent `block_on` calls from multiple
|
||||
/// worker threads.
|
||||
#[derive(Resource, Clone)]
|
||||
pub struct TokioRuntimeResource(pub Arc<tokio::runtime::Runtime>);
|
||||
|
||||
impl Default for TokioRuntimeResource {
|
||||
fn default() -> Self {
|
||||
// Building the Tokio runtime is startup-time initialization; failure
|
||||
// here means the OS refused to create threads, which is unrecoverable.
|
||||
let rt = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(2)
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("failed to build shared Tokio runtime");
|
||||
Self(Arc::new(rt))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! changes flow through automatically.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{AppLifecycle, WindowResized};
|
||||
|
||||
use crate::ui_modal::ModalScrim;
|
||||
|
||||
@@ -51,15 +52,39 @@ pub struct SafeAreaAnchoredTop {
|
||||
pub base_top: f32,
|
||||
}
|
||||
|
||||
/// Marker for `Node` entities whose `bottom` offset should be re-applied
|
||||
/// as `base_bottom + SafeAreaInsets::bottom / scale`.
|
||||
///
|
||||
/// Use this for elements anchored to the bottom edge (e.g. a bottom action
|
||||
/// bar) so they clear the Android gesture-navigation zone automatically.
|
||||
#[derive(Component, Debug, Clone, Copy)]
|
||||
pub struct SafeAreaAnchoredBottom {
|
||||
pub base_bottom: f32,
|
||||
}
|
||||
|
||||
pub struct SafeAreaInsetsPlugin;
|
||||
|
||||
impl Plugin for SafeAreaInsetsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SafeAreaInsets>()
|
||||
.add_systems(Update, (apply_safe_area_anchors, apply_safe_area_to_modal_scrims));
|
||||
// Both message types may already be registered by GamePlugin / TablePlugin;
|
||||
// add_message is idempotent.
|
||||
app.add_message::<AppLifecycle>()
|
||||
.add_message::<WindowResized>()
|
||||
.init_resource::<SafeAreaInsets>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
apply_safe_area_anchors,
|
||||
apply_safe_area_bottom_anchors,
|
||||
apply_safe_area_to_modal_scrims,
|
||||
on_app_resumed,
|
||||
),
|
||||
);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.add_systems(Update, android::refresh_insets);
|
||||
app.init_resource::<android::SafeAreaPollTries>()
|
||||
.add_systems(Update, android::refresh_insets)
|
||||
.add_systems(Update, android::rearm_on_resumed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +114,23 @@ fn apply_safe_area_anchors(
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
||||
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||||
fn apply_safe_area_bottom_anchors(
|
||||
insets: Res<SafeAreaInsets>,
|
||||
windows: Query<&Window>,
|
||||
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
||||
) {
|
||||
if !insets.is_changed() {
|
||||
return;
|
||||
}
|
||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||
let bottom_logical = insets.bottom / scale;
|
||||
for (anchor, mut node) in &mut q {
|
||||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pads the bottom of every [`ModalScrim`] by the logical bottom inset so
|
||||
/// modal cards don't extend into the Android gesture-navigation zone.
|
||||
///
|
||||
@@ -112,33 +154,73 @@ fn apply_safe_area_to_modal_scrims(
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a synthetic `WindowResized` on `AppLifecycle::WillResume` so that
|
||||
/// `on_window_resized` (in `table_plugin`) recomputes the board layout with
|
||||
/// whatever `SafeAreaInsets` are current at that moment.
|
||||
///
|
||||
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||||
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
|
||||
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
|
||||
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
|
||||
/// synthetic `WindowResized` and the layout converges to the right position.
|
||||
///
|
||||
/// 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
|
||||
/// desktop) even though insets are always zero.
|
||||
fn on_app_resumed(
|
||||
mut lifecycle: MessageReader<AppLifecycle>,
|
||||
windows: Query<(Entity, &Window)>,
|
||||
mut resize_events: MessageWriter<WindowResized>,
|
||||
) {
|
||||
for event in lifecycle.read() {
|
||||
if !matches!(event, AppLifecycle::WillResume) {
|
||||
continue;
|
||||
}
|
||||
let Some((entity, window)) = windows.iter().next() else {
|
||||
return;
|
||||
};
|
||||
resize_events.write(WindowResized {
|
||||
window: entity,
|
||||
width: window.resolution.width(),
|
||||
height: window.resolution.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android {
|
||||
use super::SafeAreaInsets;
|
||||
use super::{AppLifecycle, SafeAreaInsets};
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||
/// when `AppLifecycle::WillResume` fires, causing the poller to re-query JNI
|
||||
/// after a background/foreground cycle.
|
||||
#[derive(Resource, Default)]
|
||||
pub(super) struct SafeAreaPollTries(pub u32);
|
||||
|
||||
/// Polls Android for safe-area insets until we get a non-zero
|
||||
/// reading, then stops. `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(
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
mut tries: Local<u32>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
) {
|
||||
// Cap retries so we don't burn CPU forever on edge-to-edge
|
||||
// devices that genuinely report zero insets.
|
||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||
|
||||
if *tries >= MAX_TRIES || insets.is_populated() {
|
||||
if poll.0 >= MAX_TRIES || insets.is_populated() {
|
||||
return;
|
||||
}
|
||||
*tries += 1;
|
||||
poll.0 += 1;
|
||||
|
||||
match query_insets() {
|
||||
Ok(v) if v.is_populated() => {
|
||||
info!(
|
||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||
v.top, v.bottom, v.left, v.right, *tries
|
||||
v.top, v.bottom, v.left, v.right, poll.0
|
||||
);
|
||||
*insets = v;
|
||||
}
|
||||
@@ -147,13 +229,35 @@ mod android {
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't spam — log once and let polling continue silently.
|
||||
if *tries == 1 {
|
||||
if poll.0 == 1 {
|
||||
warn!("safe_area: JNI query failed (will retry): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the inset poller and clears cached insets on
|
||||
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
|
||||
/// frames immediately after the app returns to the foreground.
|
||||
///
|
||||
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
|
||||
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
|
||||
/// `WindowResized`. `on_window_resized` then recomputes the layout;
|
||||
/// once `refresh_insets` resolves the real values a second synthetic
|
||||
/// `WindowResized` fires and the layout converges to the correct position.
|
||||
pub(super) fn rearm_on_resumed(
|
||||
mut lifecycle: MessageReader<AppLifecycle>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
) {
|
||||
for event in lifecycle.read() {
|
||||
if matches!(event, AppLifecycle::WillResume) {
|
||||
poll.0 = 0;
|
||||
*insets = SafeAreaInsets::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{objects::JObject, JavaVM};
|
||||
|
||||
@@ -156,7 +156,13 @@ impl Plugin for SelectionPlugin {
|
||||
.in_set(SelectionKeySet)
|
||||
.before(GameMutation),
|
||||
clear_selection_on_state_change.after(GameMutation),
|
||||
update_selection_highlight.after(GameMutation),
|
||||
update_selection_highlight
|
||||
.after(GameMutation)
|
||||
.run_if(
|
||||
resource_changed::<SelectionState>
|
||||
.or(resource_changed::<KeyboardDragState>)
|
||||
.or(resource_changed::<crate::GameStateResource>),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -401,8 +401,10 @@ impl Plugin for SettingsPlugin {
|
||||
update_anim_speed_text,
|
||||
update_color_blind_text,
|
||||
update_high_contrast_text,
|
||||
update_high_contrast_borders,
|
||||
update_high_contrast_backgrounds,
|
||||
update_high_contrast_borders
|
||||
.run_if(resource_changed::<SettingsResource>),
|
||||
update_high_contrast_backgrounds
|
||||
.run_if(resource_changed::<SettingsResource>),
|
||||
update_reduce_motion_text,
|
||||
update_tooltip_delay_text,
|
||||
update_time_bonus_multiplier_text,
|
||||
|
||||
@@ -26,12 +26,12 @@ use solitaire_sync::{merge, SyncPayload, SyncResponse};
|
||||
|
||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||
use crate::events::{
|
||||
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncCompleteEvent,
|
||||
SyncConfigureRequestEvent,
|
||||
GameWonEvent, ManualSyncRequestEvent, SyncCompleteEvent, SyncConfigureRequestEvent,
|
||||
WarningToastEvent,
|
||||
};
|
||||
use crate::game_plugin::RecordingReplay;
|
||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource};
|
||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource};
|
||||
use crate::stats_plugin::{LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -101,6 +101,7 @@ impl SyncPlugin {
|
||||
impl Plugin for SyncPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(SyncProviderResource(self.provider.clone()))
|
||||
.init_resource::<TokioRuntimeResource>()
|
||||
.init_resource::<SyncStatusResource>()
|
||||
.init_resource::<PullTaskResult>()
|
||||
.init_resource::<PullTask>()
|
||||
@@ -108,7 +109,7 @@ impl Plugin for SyncPlugin {
|
||||
.add_message::<ManualSyncRequestEvent>()
|
||||
.add_message::<SyncCompleteEvent>()
|
||||
.add_message::<SyncConfigureRequestEvent>()
|
||||
.add_message::<InfoToastEvent>()
|
||||
.add_message::<WarningToastEvent>()
|
||||
.add_systems(Startup, start_pull)
|
||||
.add_systems(
|
||||
Update,
|
||||
@@ -130,19 +131,14 @@ impl Plugin for SyncPlugin {
|
||||
/// Startup system: spawns the async pull task and sets status to `Syncing`.
|
||||
fn start_pull(
|
||||
provider: Res<SyncProviderResource>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
mut task_res: ResMut<PullTask>,
|
||||
mut status: ResMut<SyncStatusResource>,
|
||||
) {
|
||||
let provider = provider.0.clone();
|
||||
let rt = rt.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
// Bevy's AsyncComputeTaskPool uses async-executor (not Tokio), but
|
||||
// reqwest/hyper require a Tokio reactor for DNS and HTTP I/O. Provide
|
||||
// a short-lived single-threaded runtime for this network round-trip.
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(provider.pull())
|
||||
rt.block_on(provider.pull())
|
||||
});
|
||||
task_res.0 = Some(task);
|
||||
status.0 = SyncStatus::Syncing;
|
||||
@@ -153,6 +149,7 @@ fn start_pull(
|
||||
fn handle_manual_sync_request(
|
||||
mut events: MessageReader<ManualSyncRequestEvent>,
|
||||
provider: Res<SyncProviderResource>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
mut task_res: ResMut<PullTask>,
|
||||
mut status: ResMut<SyncStatusResource>,
|
||||
) {
|
||||
@@ -164,12 +161,9 @@ fn handle_manual_sync_request(
|
||||
return; // Already pulling — ignore.
|
||||
}
|
||||
let provider = provider.0.clone();
|
||||
let rt = rt.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(provider.pull())
|
||||
rt.block_on(provider.pull())
|
||||
});
|
||||
task_res.0 = Some(task);
|
||||
status.0 = SyncStatus::Syncing;
|
||||
@@ -197,7 +191,7 @@ fn poll_pull_result(
|
||||
progress_path: Res<ProgressStoragePath>,
|
||||
mut complete_writer: MessageWriter<SyncCompleteEvent>,
|
||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||
mut toast: MessageWriter<InfoToastEvent>,
|
||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||
) {
|
||||
let Some(task) = task_res.0.as_mut() else {
|
||||
return;
|
||||
@@ -251,13 +245,13 @@ fn poll_pull_result(
|
||||
SyncError::Serialization(_) => format!("Unexpected server response: {e}"),
|
||||
SyncError::UnsupportedPlatform => unreachable!("handled above"),
|
||||
};
|
||||
warning_toast.write(WarningToastEvent(msg.clone()));
|
||||
// On auth failure, reopen the Connect modal so the player can
|
||||
// re-enter credentials without having to navigate through Settings.
|
||||
// `open_sync_setup_modal` is idempotent — it ignores the event when
|
||||
// the modal is already on screen, so repeated pull failures don't
|
||||
// stack multiple modals.
|
||||
if matches!(e, SyncError::Auth(_)) {
|
||||
toast.write(InfoToastEvent("Session expired — please reconnect".to_string()));
|
||||
configure_sync.write(SyncConfigureRequestEvent);
|
||||
}
|
||||
status.0 = SyncStatus::Error(msg.clone());
|
||||
@@ -274,6 +268,7 @@ fn poll_pull_result(
|
||||
fn push_on_exit(
|
||||
mut exit_events: MessageReader<AppExit>,
|
||||
provider: Res<SyncProviderResource>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
stats: Res<StatsResource>,
|
||||
achievements: Res<AchievementsResource>,
|
||||
progress: Res<ProgressResource>,
|
||||
@@ -284,21 +279,7 @@ fn push_on_exit(
|
||||
exit_events.clear();
|
||||
|
||||
let payload = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||
let provider = provider.0.clone();
|
||||
|
||||
// Prefer an existing tokio runtime; fall back to a temporary one for
|
||||
// environments (e.g. tests, Android's non-Tokio async executor) where
|
||||
// reqwest/hyper would otherwise panic with "no reactor running".
|
||||
let result = match tokio::runtime::Handle::try_current() {
|
||||
Ok(handle) => handle.block_on(provider.push(&payload)),
|
||||
Err(_) => match tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
{
|
||||
Ok(rt) => rt.block_on(provider.push(&payload)),
|
||||
Err(e) => Err(SyncError::Network(format!("tokio rt on exit: {e}"))),
|
||||
},
|
||||
};
|
||||
let result = rt.0.block_on(provider.0.push(&payload));
|
||||
match result {
|
||||
Ok(_) => {}
|
||||
// `UnsupportedPlatform` is the expected response of
|
||||
@@ -327,6 +308,7 @@ fn push_on_exit(
|
||||
fn push_replay_on_win(
|
||||
mut wins: MessageReader<GameWonEvent>,
|
||||
provider: Res<SyncProviderResource>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
game: Res<GameStateResource>,
|
||||
recording: Res<RecordingReplay>,
|
||||
mut pending: ResMut<PendingReplayUpload>,
|
||||
@@ -340,7 +322,7 @@ fn push_replay_on_win(
|
||||
}
|
||||
let replay = Replay::new(
|
||||
game.0.seed,
|
||||
game.0.draw_mode.clone(),
|
||||
game.0.draw_mode,
|
||||
game.0.mode,
|
||||
ev.time_seconds,
|
||||
ev.score,
|
||||
@@ -348,12 +330,9 @@ fn push_replay_on_win(
|
||||
recording.moves.clone(),
|
||||
);
|
||||
let provider = provider.0.clone();
|
||||
let rt = rt.0.clone();
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(provider.push_replay(&replay))
|
||||
rt.block_on(provider.push_replay(&replay))
|
||||
});
|
||||
// If a previous upload is still in flight, drop it — the most
|
||||
// recent win is the one whose share link the player will care
|
||||
@@ -571,6 +550,33 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pull_failure_fires_warning_toast() {
|
||||
use bevy::ecs::message::Messages;
|
||||
let mut app = headless_app_with(FailingProvider);
|
||||
let deadline =
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(5);
|
||||
loop {
|
||||
app.update();
|
||||
if matches!(
|
||||
app.world().resource::<SyncStatusResource>().0,
|
||||
SyncStatus::Error(_)
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if std::time::Instant::now() >= deadline {
|
||||
break;
|
||||
}
|
||||
std::thread::yield_now();
|
||||
}
|
||||
let msgs = app.world().resource::<Messages<WarningToastEvent>>();
|
||||
let mut cursor = msgs.get_cursor();
|
||||
assert!(
|
||||
cursor.read(msgs).next().is_some(),
|
||||
"a WarningToastEvent must fire when the pull fails"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_payload_sets_nil_user_id() {
|
||||
let payload = build_payload(
|
||||
|
||||
@@ -53,6 +53,7 @@ use crate::events::{
|
||||
};
|
||||
use crate::font_plugin::FontResource;
|
||||
use crate::settings_plugin::{SettingsResource, SettingsScreen, SettingsStoragePath};
|
||||
use crate::resources::TokioRuntimeResource;
|
||||
use crate::sync_plugin::SyncProviderResource;
|
||||
use crate::ui_modal::spawn_modal;
|
||||
use crate::ui_theme::{
|
||||
@@ -301,6 +302,7 @@ fn handle_auth_button(
|
||||
login_q: Query<&Interaction, (Changed<Interaction>, With<SyncLoginButton>)>,
|
||||
register_q: Query<&Interaction, (Changed<Interaction>, With<SyncRegisterButton>)>,
|
||||
fields: Query<(&SyncFieldKind, &SyncFieldBuffer)>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
mut pending: ResMut<PendingAuthTask>,
|
||||
mut error_nodes: Query<(&mut Text, &mut TextColor), With<SyncAuthError>>,
|
||||
mut busy_nodes: Query<&mut Visibility, With<SyncBusyOverlay>>,
|
||||
@@ -363,13 +365,10 @@ fn handle_auth_button(
|
||||
let is_register = register_clicked;
|
||||
let client = SolitaireServerClient::new(url.clone(), username.clone());
|
||||
let pw = password.clone();
|
||||
let rt = rt.0.clone();
|
||||
|
||||
let task = AsyncComputeTaskPool::get().spawn(async move {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(async {
|
||||
rt.block_on(async {
|
||||
let (access_token, refresh_token) = if is_register {
|
||||
client.register(&pw).await?
|
||||
} else {
|
||||
@@ -575,6 +574,7 @@ fn handle_delete_cancel(
|
||||
fn handle_delete_confirm(
|
||||
confirm_q: Query<&Interaction, (Changed<Interaction>, With<DeleteConfirmButton>)>,
|
||||
provider: Res<SyncProviderResource>,
|
||||
rt: Res<TokioRuntimeResource>,
|
||||
mut pending: ResMut<PendingDeleteTask>,
|
||||
screen: Query<Entity, With<DeleteConfirmScreen>>,
|
||||
mut commands: Commands,
|
||||
@@ -587,12 +587,9 @@ fn handle_delete_confirm(
|
||||
commands.entity(entity).despawn();
|
||||
}
|
||||
let provider = provider.0.clone();
|
||||
let rt = rt.0.clone();
|
||||
pending.0 = Some(AsyncComputeTaskPool::get().spawn(async move {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| SyncError::Network(format!("tokio rt: {e}")))?
|
||||
.block_on(provider.delete_account())
|
||||
rt.block_on(provider.delete_account())
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
@@ -129,7 +129,7 @@ fn load_initial_theme(
|
||||
let id = settings
|
||||
.as_deref()
|
||||
.map(|s| s.0.selected_theme_id.as_str())
|
||||
.unwrap_or("dark");
|
||||
.unwrap_or("classic");
|
||||
let url = bundled_theme_url(id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
||||
|
||||
@@ -212,6 +212,13 @@ where
|
||||
// modal at `Z_PAUSE` (220) in some scenes.
|
||||
GlobalZIndex(z_panel),
|
||||
ZIndex(z_panel),
|
||||
// B0004: ModalCard carries Transform (for the scale animation).
|
||||
// Bevy's GlobalTransform hook fires B0004 when a child has
|
||||
// GlobalTransform but the parent does not. Adding Identity
|
||||
// Transform here gives the scrim GlobalTransform so the check
|
||||
// passes. UI layout still uses UiTransform; this has no layout
|
||||
// effect.
|
||||
Transform::default(),
|
||||
))
|
||||
.with_children(|root| {
|
||||
root.spawn((
|
||||
@@ -603,7 +610,7 @@ pub fn dismiss_modal_on_scrim_click(
|
||||
mut commands: Commands,
|
||||
mouse: Option<Res<ButtonInput<MouseButton>>>,
|
||||
windows: Query<&Window, With<PrimaryWindow>>,
|
||||
scrims: Query<Entity, (With<ModalScrim>, With<ScrimDismissible>)>,
|
||||
scrims: Query<(Entity, &Children), (With<ModalScrim>, With<ScrimDismissible>)>,
|
||||
cards: Query<(&UiGlobalTransform, &ComputedNode), With<ModalCard>>,
|
||||
) {
|
||||
let Some(mouse) = mouse else { return };
|
||||
@@ -620,15 +627,19 @@ pub fn dismiss_modal_on_scrim_click(
|
||||
// Topmost-only: bail after the first dismissible scrim. Stacked
|
||||
// dismissible modals are not currently a real case, but this guard
|
||||
// keeps the behaviour predictable if they ever arise.
|
||||
let Some(scrim_entity) = scrims.iter().next() else {
|
||||
let Some((scrim_entity, scrim_children)) = scrims.iter().next() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cursor_over_card = cards.iter().any(|(transform, computed)| {
|
||||
// Only test the ModalCard(s) that belong to THIS scrim, not cards
|
||||
// from any other concurrently-open modal.
|
||||
let cursor_over_card = scrim_children.iter().any(|child| {
|
||||
cards.get(child).is_ok_and(|(transform, computed)| {
|
||||
let inv = computed.inverse_scale_factor;
|
||||
let size_logical = computed.size() * inv;
|
||||
let centre_logical = transform.translation * inv;
|
||||
cursor_is_inside_rect(cursor, centre_logical, size_logical)
|
||||
})
|
||||
});
|
||||
|
||||
if !cursor_over_card {
|
||||
|
||||
@@ -313,10 +313,10 @@ impl HighContrastBackground {
|
||||
/// `outline` from the design system. `#505050`.
|
||||
pub const BORDER_STRONG: Color = Color::srgba(0.314, 0.314, 0.314, 1.0);
|
||||
|
||||
/// 2 px ring drawn around the focused interactive element. Cyan
|
||||
/// 2 px ring drawn around the focused interactive element. Brick-red
|
||||
/// (matches `ACCENT_PRIMARY`) at 85% alpha so the ring stays legible
|
||||
/// against both elevated surfaces and the modal scrim backdrop.
|
||||
/// `rgba(111, 194, 239, 0.85)`.
|
||||
/// `rgba(165, 66, 66, 0.85)`.
|
||||
pub const FOCUS_RING: Color = Color::srgba(0.647, 0.259, 0.259, 0.85);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -401,8 +401,13 @@ pub const Z_BACKGROUND: i32 = -10;
|
||||
pub const Z_PILE_MARKER: i32 = -1;
|
||||
/// Base layer for HUD readouts (top-left).
|
||||
pub const Z_HUD: i32 = 50;
|
||||
/// Action bar + popovers — above HUD readouts so dropdowns can overlap.
|
||||
pub const Z_HUD_TOP: i32 = 60;
|
||||
/// Fullscreen transparent dismiss-backdrop spawned behind a HUD popover so
|
||||
/// tapping outside it light-dismisses the panel without blocking other input.
|
||||
pub const Z_HUD_POPOVER_BACKDROP: i32 = Z_HUD + 4;
|
||||
/// HUD popovers (Modes dropdown, etc.) — above the dismiss backdrop.
|
||||
pub const Z_HUD_POPOVER: i32 = Z_HUD + 5;
|
||||
/// Transient HUD annotations (score-delta floaters) — above popovers.
|
||||
pub const Z_HUD_TOP: i32 = Z_HUD + 10;
|
||||
pub const Z_MODAL_SCRIM: i32 = 200;
|
||||
pub const Z_MODAL_PANEL: i32 = 210;
|
||||
/// Pause overlay outranks normal modals — pausing should always be on top.
|
||||
@@ -648,6 +653,8 @@ mod tests {
|
||||
Z_BACKGROUND,
|
||||
Z_PILE_MARKER,
|
||||
Z_HUD,
|
||||
Z_HUD_POPOVER_BACKDROP,
|
||||
Z_HUD_POPOVER,
|
||||
Z_HUD_TOP,
|
||||
Z_MODAL_SCRIM,
|
||||
Z_MODAL_PANEL,
|
||||
|
||||
@@ -82,7 +82,7 @@ fn evaluate_weekly_goals(
|
||||
let ctx = WeeklyGoalContext {
|
||||
time_seconds: ev.time_seconds,
|
||||
used_undo: game.0.undo_count > 0,
|
||||
draw_mode: game.0.draw_mode.clone(),
|
||||
draw_mode: game.0.draw_mode,
|
||||
};
|
||||
for def in WEEKLY_GOALS {
|
||||
if !def.matches(&ctx) {
|
||||
|
||||
@@ -336,7 +336,7 @@ pub async fn get_me(
|
||||
|
||||
Ok(Json(MeResponse {
|
||||
id: user.user_id,
|
||||
username: row.username.unwrap_or_default(),
|
||||
username: row.username.ok_or(AppError::Unauthorized)?,
|
||||
avatar_url: row.avatar_url,
|
||||
}))
|
||||
}
|
||||
@@ -386,13 +386,19 @@ pub async fn upload_avatar(
|
||||
std::fs::create_dir_all("avatars").map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
let filename = format!("{}.{}", user.user_id, ext);
|
||||
let path = std::path::Path::new("avatars").join(&filename);
|
||||
// Remove stale files with other extensions first.
|
||||
let tmp_path = std::path::Path::new("avatars").join(format!("{}.{}.tmp", user.user_id, ext));
|
||||
// Write to a temp file then atomically rename so concurrent readers never
|
||||
// see a partially-written avatar.
|
||||
std::fs::write(&tmp_path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
std::fs::rename(&tmp_path, &path).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
// Remove stale files with other extensions after the atomic rename.
|
||||
for old_ext in &["jpg", "png", "webp", "gif"] {
|
||||
if *old_ext != ext {
|
||||
let _ = std::fs::remove_file(
|
||||
std::path::Path::new("avatars").join(format!("{}.{}", user.user_id, old_ext)),
|
||||
);
|
||||
}
|
||||
std::fs::write(&path, &body).map_err(|e| AppError::Internal(e.to_string()))?;
|
||||
}
|
||||
|
||||
let avatar_url = format!("/avatars/{filename}");
|
||||
sqlx::query!(
|
||||
@@ -412,7 +418,7 @@ pub async fn upload_avatar(
|
||||
|
||||
Ok(Json(MeResponse {
|
||||
id: user.user_id,
|
||||
username: username.unwrap_or_default(),
|
||||
username: username.ok_or(AppError::Unauthorized)?,
|
||||
avatar_url: Some(avatar_url),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -146,6 +146,7 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
.route("/api/account", delete(auth::delete_account))
|
||||
.route("/api/me", get(auth::get_me))
|
||||
.route("/api/me/avatar", put(auth::upload_avatar))
|
||||
.nest_service("/avatars", ServeDir::new("avatars"))
|
||||
.layer(axum_middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
middleware::require_auth,
|
||||
@@ -231,7 +232,6 @@ fn build_router_inner(state: AppState, rate_limit: bool) -> Router {
|
||||
)
|
||||
.nest_service("/web", ServeDir::new("solitaire_server/web"))
|
||||
.nest_service("/assets", ServeDir::new("assets"))
|
||||
.nest_service("/avatars", ServeDir::new("avatars"))
|
||||
.layer(axum_middleware::from_fn(security_headers));
|
||||
|
||||
Router::new()
|
||||
|
||||
@@ -59,6 +59,25 @@ header {
|
||||
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
|
||||
.hud-right { display: flex; align-items: center; gap: 10px; }
|
||||
|
||||
.hud-avatar-link { display: flex; align-items: center; text-decoration: none; }
|
||||
.hud-avatar-inner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 2px solid rgba(255,255,255,0.15);
|
||||
background: var(--panel-hi);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
transition: border-color 120ms;
|
||||
}
|
||||
.hud-avatar-link:hover .hud-avatar-inner { border-color: var(--accent); }
|
||||
.hud-avatar-inner img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
|
||||
|
||||
.logo { font-size: 16px; font-weight: 700; }
|
||||
.muted { color: var(--text-muted); font-size: 12px; }
|
||||
.home-link {
|
||||
@@ -98,6 +117,16 @@ button:disabled { opacity: 0.4; cursor: default; }
|
||||
|
||||
/* ── Board ───────────────────────────────────────────────────────────── */
|
||||
|
||||
.board-undo {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 24px;
|
||||
z-index: 50;
|
||||
font-size: 15px;
|
||||
padding: 8px 20px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -108,6 +137,7 @@ main {
|
||||
|
||||
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
|
||||
#board {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
background: var(--felt);
|
||||
display: flex;
|
||||
|
||||
@@ -40,12 +40,19 @@
|
||||
<input type="checkbox" id="chk-draw3"> Draw 3
|
||||
</label>
|
||||
<button id="btn-theme" title="Switch card theme">Dark</button>
|
||||
<a id="hud-avatar" href="/account" title="Account" class="hud-avatar-link" style="display:none">
|
||||
<div class="hud-avatar-inner">
|
||||
<img id="hud-avatar-img" src="" alt="" style="display:none">
|
||||
<span id="hud-avatar-initials"></span>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section id="board">
|
||||
<div id="card-area"></div>
|
||||
<button id="btn-board-undo" class="board-undo" title="Undo (Z)" disabled>↩ Undo</button>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -104,6 +104,7 @@ const hudTimer = document.getElementById("hud-timer");
|
||||
const hudStock = document.getElementById("hud-stock");
|
||||
const hudSeed = document.getElementById("hud-seed");
|
||||
const btnUndo = document.getElementById("btn-undo");
|
||||
const btnBoardUndo = document.getElementById("btn-board-undo");
|
||||
const btnNew = document.getElementById("btn-new");
|
||||
const chkDraw3 = document.getElementById("chk-draw3");
|
||||
const btnTheme = document.getElementById("btn-theme");
|
||||
@@ -244,6 +245,7 @@ function render(s) {
|
||||
hudMoves.textContent = `Moves: ${s.move_count}`;
|
||||
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
|
||||
btnUndo.disabled = s.undo_stack_len === 0;
|
||||
btnBoardUndo.disabled = s.undo_stack_len === 0;
|
||||
|
||||
const visible = new Map();
|
||||
const addPile = (name, cards) =>
|
||||
@@ -385,10 +387,9 @@ function flashIllegal(cardIds) {
|
||||
|
||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||
function attachHandlers() {
|
||||
btnUndo.addEventListener("click", () => {
|
||||
const r = game.undo();
|
||||
if (r.ok) render(r.snapshot);
|
||||
});
|
||||
const doUndo = () => { const r = game.undo(); if (r.ok) render(r.snapshot); };
|
||||
btnUndo.addEventListener("click", doUndo);
|
||||
btnBoardUndo.addEventListener("click", doUndo);
|
||||
btnNew.addEventListener("click", () => startGame(randomSeed()));
|
||||
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
|
||||
chkDraw3.addEventListener("change", () => {
|
||||
@@ -404,7 +405,7 @@ function attachHandlers() {
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.target.tagName === "INPUT") return;
|
||||
if (e.key === "z" || e.key === "Z") { const r = game.undo(); if (r.ok) render(r.snapshot); }
|
||||
if (e.key === "z" || e.key === "Z") doUndo();
|
||||
if (e.key === "n" || e.key === "N") startGame(randomSeed());
|
||||
});
|
||||
|
||||
@@ -662,5 +663,31 @@ function onBoardDblClick(e) {
|
||||
if (!smartMove(hit.pileName, fromIndex)) flashIllegal([cards[fromIndex].id]);
|
||||
}
|
||||
|
||||
// ── Avatar ────────────────────────────────────────────────────────────────────
|
||||
async function loadAvatar() {
|
||||
const token = localStorage.getItem("fs_token");
|
||||
if (!token) return;
|
||||
try {
|
||||
const res = await fetch("/api/me", {
|
||||
headers: { Authorization: "Bearer " + token },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const me = await res.json();
|
||||
const link = document.getElementById("hud-avatar");
|
||||
const img = document.getElementById("hud-avatar-img");
|
||||
const init = document.getElementById("hud-avatar-initials");
|
||||
link.style.display = "flex";
|
||||
if (me.avatar_url) {
|
||||
img.src = me.avatar_url;
|
||||
img.style.display = "block";
|
||||
init.style.display = "none";
|
||||
} else {
|
||||
img.style.display = "none";
|
||||
init.textContent = (me.username || "P")[0].toUpperCase();
|
||||
}
|
||||
} catch { /* not signed in — avatar stays hidden */ }
|
||||
}
|
||||
|
||||
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||
bootstrap().catch(console.error);
|
||||
loadAvatar();
|
||||
|
||||
@@ -30,7 +30,8 @@
|
||||
<section id="board"></section>
|
||||
|
||||
<section id="controls">
|
||||
<button id="btn-prev" disabled>⏮ Restart</button>
|
||||
<button id="btn-restart" disabled>⏮ Restart</button>
|
||||
<button id="btn-prev" disabled>◀ Back</button>
|
||||
<button id="btn-play">▶ Play</button>
|
||||
<button id="btn-step">⏭ Step</button>
|
||||
<span id="progress" class="muted">step 0 / 0</span>
|
||||
|
||||
@@ -62,6 +62,7 @@ const resultEl = document.getElementById("result");
|
||||
const btnPlay = document.getElementById("btn-play");
|
||||
const btnStep = document.getElementById("btn-step");
|
||||
const btnPrev = document.getElementById("btn-prev");
|
||||
const btnRestart = document.getElementById("btn-restart");
|
||||
|
||||
let player = null;
|
||||
let replayJson = null;
|
||||
@@ -122,6 +123,7 @@ function resetPlayer() {
|
||||
}
|
||||
player = new ReplayPlayer(replayJson);
|
||||
btnPrev.disabled = true;
|
||||
btnRestart.disabled = true;
|
||||
btnStep.disabled = false;
|
||||
btnPlay.disabled = false;
|
||||
render(player.state());
|
||||
@@ -134,6 +136,7 @@ function step() {
|
||||
return null;
|
||||
}
|
||||
btnPrev.disabled = false;
|
||||
btnRestart.disabled = false;
|
||||
render(snap);
|
||||
return snap;
|
||||
}
|
||||
@@ -301,13 +304,35 @@ btnPlay.addEventListener("click", () => {
|
||||
}, STEP_INTERVAL_MS);
|
||||
});
|
||||
|
||||
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
||||
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
||||
/// renders once so the CSS transition animates each card to its previous
|
||||
/// position.
|
||||
function stepBack() {
|
||||
if (!player || player.step_idx() === 0) return;
|
||||
if (playInterval) {
|
||||
clearInterval(playInterval);
|
||||
playInterval = null;
|
||||
btnPlay.textContent = "▶ Play";
|
||||
}
|
||||
const target = player.step_idx() - 1;
|
||||
player = new ReplayPlayer(replayJson);
|
||||
for (let i = 0; i < target; i++) {
|
||||
player.step();
|
||||
}
|
||||
render(player.state());
|
||||
btnPrev.disabled = player.step_idx() === 0;
|
||||
btnRestart.disabled = player.step_idx() === 0;
|
||||
btnStep.disabled = false;
|
||||
btnPlay.disabled = false;
|
||||
}
|
||||
|
||||
btnPrev.addEventListener("click", () => {
|
||||
if (player) stepBack();
|
||||
});
|
||||
|
||||
btnRestart.addEventListener("click", () => {
|
||||
if (!replayJson) return;
|
||||
// Drop every existing card so the next render fades them all in
|
||||
// at the freshly-dealt positions. Without this, cards from the
|
||||
// current state would slide to wherever the new deal puts them
|
||||
// — confusing since the deal is supposed to look like a fresh
|
||||
// start, not a continuation.
|
||||
cardEls.forEach((el) => el.remove());
|
||||
cardEls.clear();
|
||||
resetPlayer();
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod progress;
|
||||
pub mod stats;
|
||||
|
||||
pub use achievements::AchievementRecord;
|
||||
pub use merge::merge;
|
||||
pub use merge::{merge, merge_at};
|
||||
pub use progress::{level_for_xp, PlayerProgress};
|
||||
pub use stats::StatsSnapshot;
|
||||
|
||||
|
||||
+43
-18
@@ -3,13 +3,18 @@
|
||||
//! All functions are free of I/O and side effects — safe to call from any
|
||||
//! context including unit tests and the Bevy main thread.
|
||||
|
||||
use chrono::{NaiveDate, Utc};
|
||||
use chrono::{DateTime, NaiveDate, Utc};
|
||||
|
||||
use crate::{AchievementRecord, ConflictReport, PlayerProgress, StatsSnapshot, SyncPayload};
|
||||
use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
||||
|
||||
/// Merge two [`SyncPayload`]s into a single authoritative result.
|
||||
///
|
||||
/// `resolved_at` is recorded as `last_modified` on the merged payload and all
|
||||
/// sub-structs. Pass an explicit timestamp when the caller needs deterministic
|
||||
/// output (e.g. server handlers); use [`merge`] as a convenience wrapper that
|
||||
/// captures the current time automatically.
|
||||
///
|
||||
/// The merge strategy is additive and conflict-free for most fields:
|
||||
/// - Counters: take the maximum (games_played, games_won, etc.)
|
||||
/// - Best records: take the minimum for times, maximum for scores/xp
|
||||
@@ -20,6 +25,38 @@ use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
||||
/// Fields that cannot be merged deterministically (e.g. diverged streak
|
||||
/// counts) are recorded in [`ConflictReport`] entries returned alongside
|
||||
/// the merged payload. Data is never silently discarded.
|
||||
pub fn merge_at(
|
||||
local: &SyncPayload,
|
||||
remote: &SyncPayload,
|
||||
resolved_at: DateTime<Utc>,
|
||||
) -> (SyncPayload, Vec<ConflictReport>) {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
if local.user_id != remote.user_id {
|
||||
conflicts.push(ConflictReport {
|
||||
field: "user_id".to_string(),
|
||||
local_value: local.user_id.to_string(),
|
||||
remote_value: remote.user_id.to_string(),
|
||||
});
|
||||
return (local.clone(), conflicts);
|
||||
}
|
||||
|
||||
let stats = merge_stats(&local.stats, &remote.stats, resolved_at, &mut conflicts);
|
||||
let achievements = merge_achievements(&local.achievements, &remote.achievements);
|
||||
let progress = merge_progress(&local.progress, &remote.progress, resolved_at, &mut conflicts);
|
||||
|
||||
let merged = SyncPayload {
|
||||
user_id: local.user_id,
|
||||
stats,
|
||||
achievements,
|
||||
progress,
|
||||
last_modified: resolved_at,
|
||||
};
|
||||
|
||||
(merged, conflicts)
|
||||
}
|
||||
|
||||
/// Convenience wrapper around [`merge_at`] that captures the current UTC time.
|
||||
///
|
||||
/// # Examples
|
||||
/// ```
|
||||
@@ -45,21 +82,7 @@ use crate::progress::{level_for_xp, DAILY_CHALLENGE_HISTORY_CAP};
|
||||
/// assert!(conflicts.is_empty());
|
||||
/// ```
|
||||
pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<ConflictReport>) {
|
||||
let mut conflicts = Vec::new();
|
||||
|
||||
let stats = merge_stats(&local.stats, &remote.stats, &mut conflicts);
|
||||
let achievements = merge_achievements(&local.achievements, &remote.achievements);
|
||||
let progress = merge_progress(&local.progress, &remote.progress, &mut conflicts);
|
||||
|
||||
let merged = SyncPayload {
|
||||
user_id: local.user_id,
|
||||
stats,
|
||||
achievements,
|
||||
progress,
|
||||
last_modified: Utc::now(),
|
||||
};
|
||||
|
||||
(merged, conflicts)
|
||||
merge_at(local, remote, Utc::now())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -69,6 +92,7 @@ pub fn merge(local: &SyncPayload, remote: &SyncPayload) -> (SyncPayload, Vec<Con
|
||||
fn merge_stats(
|
||||
local: &StatsSnapshot,
|
||||
remote: &StatsSnapshot,
|
||||
resolved_at: DateTime<Utc>,
|
||||
conflicts: &mut Vec<ConflictReport>,
|
||||
) -> StatsSnapshot {
|
||||
// win_streak_current cannot be merged deterministically — record conflict
|
||||
@@ -128,7 +152,7 @@ fn merge_stats(
|
||||
local.challenge_fastest_win_seconds,
|
||||
remote.challenge_fastest_win_seconds,
|
||||
),
|
||||
last_modified: Utc::now(),
|
||||
last_modified: resolved_at,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +237,7 @@ fn merge_achievements(
|
||||
fn merge_progress(
|
||||
local: &PlayerProgress,
|
||||
remote: &PlayerProgress,
|
||||
resolved_at: DateTime<Utc>,
|
||||
conflicts: &mut Vec<ConflictReport>,
|
||||
) -> PlayerProgress {
|
||||
// daily_challenge_streak cannot be merged deterministically.
|
||||
@@ -303,7 +328,7 @@ fn merge_progress(
|
||||
challenge_index,
|
||||
daily_challenge_history,
|
||||
daily_challenge_longest_streak,
|
||||
last_modified: Utc::now(),
|
||||
last_modified: resolved_at,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ impl ReplayPlayer {
|
||||
let replay: Replay =
|
||||
serde_json::from_str(replay_json).map_err(|e| format!("invalid replay JSON: {e}"))?;
|
||||
let game =
|
||||
GameState::new_with_mode(replay.seed, replay.draw_mode.clone(), replay.mode);
|
||||
GameState::new_with_mode(replay.seed, replay.draw_mode, replay.mode);
|
||||
Ok(Self {
|
||||
game,
|
||||
moves: replay.moves,
|
||||
@@ -193,16 +193,24 @@ impl ReplayPlayer {
|
||||
}
|
||||
|
||||
/// Snapshot the current `GameState` as a JS object (see `StateSnapshot`).
|
||||
pub fn state(&self) -> JsValue {
|
||||
serde_wasm_bindgen::to_value(&self.snapshot()).unwrap_or(JsValue::NULL)
|
||||
///
|
||||
/// Throws a JS string exception on serialisation failure (should never
|
||||
/// occur in practice — `StateSnapshot` contains only primitive types).
|
||||
pub fn state(&self) -> Result<JsValue, JsValue> {
|
||||
serde_wasm_bindgen::to_value(&self.snapshot())
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// Apply the next move; returns the post-step snapshot, or `null`
|
||||
/// once the move list is exhausted.
|
||||
pub fn step(&mut self) -> JsValue {
|
||||
///
|
||||
/// Returns `null` (not an exception) when the replay is finished.
|
||||
/// Throws a JS string exception on serialisation failure.
|
||||
pub fn step(&mut self) -> Result<JsValue, JsValue> {
|
||||
match self.step_native() {
|
||||
Some(snap) => serde_wasm_bindgen::to_value(&snap).unwrap_or(JsValue::NULL),
|
||||
None => JsValue::NULL,
|
||||
Some(snap) => serde_wasm_bindgen::to_value(&snap)
|
||||
.map_err(|e| JsValue::from_str(&e.to_string())),
|
||||
None => Ok(JsValue::NULL),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -364,8 +372,11 @@ impl SolitaireGame {
|
||||
}
|
||||
|
||||
/// Full pile snapshot as a JS object.
|
||||
pub fn state(&self) -> JsValue {
|
||||
serde_wasm_bindgen::to_value(&self.snap()).unwrap_or(JsValue::NULL)
|
||||
///
|
||||
/// Throws a JS string exception on serialisation failure.
|
||||
pub fn state(&self) -> Result<JsValue, JsValue> {
|
||||
serde_wasm_bindgen::to_value(&self.snap())
|
||||
.map_err(|e| JsValue::from_str(&e.to_string()))
|
||||
}
|
||||
|
||||
/// The seed used to deal this game.
|
||||
|
||||
Reference in New Issue
Block a user