Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d8f67dcad3 | |||
| ccb77f76b8 | |||
| da54faf8e2 | |||
| f3d01b5890 | |||
| faefca0445 | |||
| 24d83c9ae3 | |||
| 9d4234cded | |||
| e48f652454 | |||
| c24c7f6b61 | |||
| 686f57252c | |||
| 059af2ac28 | |||
| 858012d926 | |||
| f6be961419 | |||
| 8a145154db | |||
| e17667d034 | |||
| 005e29d1ab | |||
| 9d3cc94831 | |||
| a9285ccb41 | |||
| 648c3ed11d | |||
| 102506f799 | |||
| 9b00af29d9 | |||
| ea28121675 | |||
| ba17c026a3 | |||
| 6cedf36b01 | |||
| eb0831893d | |||
| ad9ac9c7bb | |||
| 5f9f2745f9 | |||
| a18bcb84d3 | |||
| d5c7a149cb | |||
| fceb2be381 | |||
| d761a150d7 | |||
| d105fee319 | |||
| 94c68a46a4 |
@@ -6,86 +6,63 @@ 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/solitaire-quest.apk
|
||||
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
||||
GITEA_URL: https://git.aleshym.co
|
||||
REPO: funman300/Rusty_Solitare
|
||||
REPO: funman300/Ferrous-Solitaire
|
||||
|
||||
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)
|
||||
@@ -106,7 +82,7 @@ jobs:
|
||||
-d "{
|
||||
\"tag_name\": \"$TAG\",
|
||||
\"name\": \"$TAG\",
|
||||
\"body\": \"## Android release $TAG\n\n**Install / update with Obtainium** — add this source URL:\n\`\`\`\nhttps://git.aleshym.co/funman300/Rusty_Solitare\n\`\`\`\n\nOr download \`solitaire-quest.apk\` below and sideload it directly.\",
|
||||
\"body\": \"## Android release $TAG\n\n**Install / update with Obtainium** — add this source URL:\n\`\`\`\nhttps://git.aleshym.co/funman300/Ferrous-Solitaire\n\`\`\`\n\nOr download \`ferrous-solitaire.apk\` below and sideload it directly.\",
|
||||
\"draft\": false,
|
||||
\"prerelease\": false
|
||||
}" \
|
||||
@@ -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
|
||||
|
||||
+8
-8
@@ -58,7 +58,7 @@ Pure-core, no-panics-in-game-logic, and UI-first-interaction constraints are enf
|
||||
## 2. Workspace Structure
|
||||
|
||||
```
|
||||
solitaire_quest/
|
||||
ferrous_solitaire/
|
||||
│
|
||||
├── Cargo.toml # Workspace manifest
|
||||
├── .env.example # Server environment variable template
|
||||
@@ -366,12 +366,12 @@ Minimum window: 800×600. At this size cards are small but usable.
|
||||
|
||||
### Local Storage
|
||||
|
||||
All files stored under `dirs::data_dir() / "solitaire_quest"/`:
|
||||
All files stored under `dirs::data_dir() / "ferrous_solitaire"/`:
|
||||
|
||||
```
|
||||
~/.local/share/solitaire_quest/ (Linux)
|
||||
~/Library/Application Support/solitaire_quest/ (macOS)
|
||||
%APPDATA%\solitaire_quest\ (Windows)
|
||||
~/.local/share/ferrous_solitaire/ (Linux)
|
||||
~/Library/Application Support/ferrous_solitaire/ (macOS)
|
||||
%APPDATA%\ferrous_solitaire\ (Windows)
|
||||
│
|
||||
├── stats.json # StatsSnapshot
|
||||
├── progress.json # PlayerProgress (XP, level, unlocks, daily challenge)
|
||||
@@ -426,7 +426,7 @@ pub enum SyncBackend {
|
||||
url: String,
|
||||
username: String,
|
||||
// JWT access + refresh tokens stored in OS keychain
|
||||
// key: "solitaire_quest_server_{username}"
|
||||
// key: "ferrous_solitaire_server_{username}"
|
||||
},
|
||||
}
|
||||
```
|
||||
@@ -980,8 +980,8 @@ Add `--features bevy/dynamic_linking` during development to dramatically reduce
|
||||
### Docker Compose (Recommended)
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourname/solitaire_quest
|
||||
cd solitaire_quest
|
||||
git clone https://github.com/yourname/ferrous_solitaire
|
||||
cd ferrous_solitaire
|
||||
cp .env.example .env
|
||||
# Edit .env — set JWT_SECRET and SERVER_PORT
|
||||
docker compose up -d
|
||||
|
||||
+36
-4
@@ -6,6 +6,38 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
- **Rename: Solitaire Quest → Ferrous Solitaire.** Android package id changed
|
||||
from `com.solitairequest.app` to `com.ferrousapp.solitaire`; existing installs
|
||||
must be uninstalled first (Android treats the new id as a new app).
|
||||
Data directory renamed from `solitaire_quest/` to `ferrous_solitaire/`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **BUG-3: Multi-modal stacking** (`hud_plugin.rs`). `handle_menu_button`
|
||||
@@ -1431,7 +1463,7 @@ candidate — the app-icon round — stays open.
|
||||
- **Android build target — first working APK** (`fb8b2ac`).
|
||||
`cargo apk build -p solitaire_app --target x86_64-linux-android`
|
||||
now produces a 54 MB debug-signed APK at
|
||||
`target/debug/apk/solitaire-quest.apk`. Five gating points
|
||||
`target/debug/apk/ferrous-solitaire.apk`. Five gating points
|
||||
resolved end-to-end:
|
||||
- **`solitaire_app` split into bin + lib.** cargo-apk needs a
|
||||
`cdylib` to bundle as `libmain.so`; pure-bin crates panic
|
||||
@@ -1548,7 +1580,7 @@ candidate — the app-icon round — stays open.
|
||||
achievements, replays, game-state, time-attack sessions, user
|
||||
themes). New `solitaire_data::platform::data_dir()` shim falls
|
||||
through to `dirs::data_dir()` on desktop and returns the per-app
|
||||
sandbox at `/data/data/com.solitairequest.app/files` on Android
|
||||
sandbox at `/data/data/com.ferrousapp.solitaire/files` on Android
|
||||
— no JNI needed, since the package id is pinned in
|
||||
`[package.metadata.android]`. Six call sites across
|
||||
`solitaire_data` plus `solitaire_engine/assets/user_dir.rs`
|
||||
@@ -1690,7 +1722,7 @@ fully reverted and is not part of this release.
|
||||
The test's single-frame `app.update()` was sensitive to
|
||||
first-frame `Time::delta_secs()` variance under heavy parallel
|
||||
cargo-test load, and to production-disk
|
||||
`~/.local/share/solitaire_quest/game_state.json` state leaking
|
||||
`~/.local/share/ferrous_solitaire/game_state.json` state leaking
|
||||
into the test world via `GamePlugin::build`'s load path.
|
||||
`test_app` now resets `PendingRestoredGame(None)` after plugin
|
||||
build (preventing the dev machine's saved-game state from
|
||||
@@ -2386,7 +2418,7 @@ the binary shipped with bundled artwork.
|
||||
patterns.
|
||||
- **Ambient audio loop** wired through the kira mixer.
|
||||
- **Arch Linux PKGBUILDs** for the game client and sync server (under
|
||||
the separate `solitaire-quest-pkgbuild` directory).
|
||||
the separate `ferrous-solitaire-pkgbuild` directory).
|
||||
- **Workspace README, CI workflow, migration guide.**
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -447,7 +447,7 @@ raw `z_index` values — they drift and cause ordering bugs.
|
||||
|
||||
```bash
|
||||
cargo apk build --package solitaire_app --lib
|
||||
adb install -r target/debug/apk/solitaire-quest.apk
|
||||
adb install -r target/debug/apk/ferrous-solitaire.apk
|
||||
```
|
||||
|
||||
## 15.2 Coordinate system reminder
|
||||
|
||||
@@ -31,6 +31,23 @@ optional self-hosted sync so your stats follow you across machines.
|
||||
- **Color-blind mode** — blue tint on red-suit cards alongside the suit
|
||||
glyph
|
||||
|
||||
## Android Install
|
||||
|
||||
### Obtainium (recommended — automatic updates)
|
||||
|
||||
1. Install [Obtainium](https://github.com/ImranR98/Obtainium/releases) on your device
|
||||
2. Tap the badge below on your Android device — the source type is pre-configured, no manual selection needed:
|
||||
|
||||
[<img src="https://raw.githubusercontent.com/ImranR98/Obtainium/main/assets/graphics/badge_obtainium.png" alt="Get it on Obtainium" height="40">](https://apps.obtainium.imranr.dev/redirect?r=obtainium://app/%7B%22id%22%3A%22com.ferrousapp.solitaire%22%2C%22url%22%3A%22https%3A//git.aleshym.co/funman300/Ferrous-Solitaire%22%2C%22author%22%3A%22funman300%22%2C%22name%22%3A%22Ferrous%20Solitaire%22%2C%22installedVersion%22%3Anull%2C%22latestVersion%22%3Anull%2C%22apkUrls%22%3A%22%5B%5D%22%2C%22preferredApkIndex%22%3A0%2C%22additionalSettings%22%3A%22%7B%7D%22%2C%22lastUpdateCheck%22%3Anull%2C%22pinned%22%3Afalse%2C%22categories%22%3A%5B%5D%2C%22releaseDate%22%3Anull%2C%22changeLog%22%3Anull%2C%22overrideSource%22%3A%22Codeberg%22%2C%22allowIdChange%22%3Afalse%2C%22otherAssetUrls%22%3A%22%5B%5D%22%7D)
|
||||
|
||||
3. Tap **Install** to download the current release — Obtainium will notify you when updates are available.
|
||||
|
||||
### Direct APK
|
||||
|
||||
Download the latest `ferrous-solitaire.apk` from the
|
||||
[Releases](https://git.aleshym.co/funman300/Ferrous-Solitaire/releases) page,
|
||||
enable **Install from unknown sources** in your device settings, and open the file.
|
||||
|
||||
## Building
|
||||
|
||||
**Prerequisites**
|
||||
|
||||
@@ -6,7 +6,7 @@ metadata:
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://git.aleshym.co/funman300/Rusty_Solitare.git
|
||||
repoURL: https://git.aleshym.co/funman300/Ferrous-Solitaire.git
|
||||
targetRevision: master
|
||||
path: deploy
|
||||
destination:
|
||||
|
||||
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: ca5d8a9c
|
||||
newTag: 858012d9
|
||||
|
||||
@@ -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
|
||||
+8
-8
@@ -6,7 +6,7 @@ later sections document what's known to compile, what's stubbed, and
|
||||
the next milestones.
|
||||
|
||||
> **Status (2026-05-07):** First working APK at `fb8b2ac`. 54 MB
|
||||
> debug-signed `solitaire-quest.apk` for `x86_64-linux-android`. Has
|
||||
> debug-signed `ferrous-solitaire.apk` for `x86_64-linux-android`. Has
|
||||
> NOT yet been verified to launch on a device or emulator — that's
|
||||
> the next milestone.
|
||||
|
||||
@@ -121,7 +121,7 @@ cargo apk build -p solitaire_app --target x86_64-linux-android
|
||||
Output:
|
||||
|
||||
```
|
||||
target/debug/apk/solitaire-quest.apk
|
||||
target/debug/apk/ferrous-solitaire.apk
|
||||
```
|
||||
|
||||
Targets shipped via `[package.metadata.android].build_targets` in
|
||||
@@ -164,8 +164,8 @@ Physical device:
|
||||
|
||||
```bash
|
||||
adb devices # confirm connection
|
||||
adb install target/debug/apk/solitaire-quest.apk
|
||||
adb shell am start -n com.solitairequest.app/android.app.NativeActivity
|
||||
adb install target/debug/apk/ferrous-solitaire.apk
|
||||
adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire|panic"
|
||||
```
|
||||
|
||||
@@ -174,7 +174,7 @@ Emulator:
|
||||
```bash
|
||||
emulator -avd bevy_test -no-window -gpu swiftshader_indirect &
|
||||
adb wait-for-device
|
||||
adb install target/debug/apk/solitaire-quest.apk
|
||||
adb install target/debug/apk/ferrous-solitaire.apk
|
||||
# ... same start + logcat steps as above.
|
||||
```
|
||||
|
||||
@@ -203,7 +203,7 @@ What's NOT yet ported / not yet measured:
|
||||
- `dirs::data_dir()` returns `None` on Android. Callers in
|
||||
`solitaire_data/src/storage.rs`, `progress.rs`, `replay.rs`,
|
||||
`achievements.rs`, `settings.rs` all need an Android-aware
|
||||
helper (likely `/data/data/com.solitairequest.app/files`).
|
||||
helper (likely `/data/data/com.ferrousapp.solitaire/files`).
|
||||
- Touch UX pass — hit-target sizes, modal scaling on small screens,
|
||||
app lifecycle (suspend / resume), font scaling.
|
||||
- Android Keystore via JNI for `auth_tokens`.
|
||||
@@ -221,8 +221,8 @@ cargo build -p solitaire_app # desktop sanity
|
||||
cargo clippy --workspace --all-targets -- -D warnings # gate
|
||||
cargo test --workspace # gate
|
||||
cargo apk build -p solitaire_app --target x86_64-linux-android --lib
|
||||
adb install -r target/debug/apk/solitaire-quest.apk # `-r` reinstalls
|
||||
adb logcat -c && adb shell am start -n com.solitairequest.app/android.app.NativeActivity
|
||||
adb install -r target/debug/apk/ferrous-solitaire.apk # `-r` reinstalls
|
||||
adb logcat -c && adb shell am start -n com.ferrousapp.solitaire/android.app.NativeActivity
|
||||
adb logcat | grep -iE "RustStdoutStderr|solitaire"
|
||||
```
|
||||
|
||||
|
||||
@@ -39,13 +39,13 @@ Before starting, delete any existing local save files to ensure a clean state:
|
||||
|
||||
```
|
||||
# Linux
|
||||
rm -rf ~/.local/share/solitaire_quest/
|
||||
rm -rf ~/.local/share/ferrous_solitaire/
|
||||
|
||||
# macOS
|
||||
rm -rf ~/Library/Application\ Support/solitaire_quest/
|
||||
rm -rf ~/Library/Application\ Support/ferrous_solitaire/
|
||||
|
||||
# Windows
|
||||
rmdir /s %APPDATA%\solitaire_quest\
|
||||
rmdir /s %APPDATA%\ferrous_solitaire\
|
||||
```
|
||||
|
||||
---
|
||||
@@ -130,10 +130,10 @@ On the machine where you want to test (Linux example):
|
||||
|
||||
```bash
|
||||
# List keychain entries (uses secret-tool on GNOME)
|
||||
secret-tool search service solitaire_quest_server
|
||||
secret-tool search service ferrous_solitaire_server
|
||||
|
||||
# Overwrite alice's access token with a deliberately invalid value
|
||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "invalid.token.value"
|
||||
secret-tool store --label="alice_access" service ferrous_solitaire_server account alice_access <<< "invalid.token.value"
|
||||
```
|
||||
|
||||
### Step 2 — Trigger a sync with the expired/invalid token
|
||||
@@ -148,7 +148,7 @@ secret-tool store --label="alice_access" service solitaire_quest_server account
|
||||
|
||||
```bash
|
||||
# Extract the new token from the keychain
|
||||
secret-tool lookup service solitaire_quest_server account alice_access | head -c 50
|
||||
secret-tool lookup service ferrous_solitaire_server account alice_access | head -c 50
|
||||
# Should look like a valid JWT (three base64 segments separated by dots)
|
||||
```
|
||||
|
||||
@@ -157,8 +157,8 @@ secret-tool lookup service solitaire_quest_server account alice_access | head -c
|
||||
1. Corrupt both the access token and the refresh token in the keychain:
|
||||
|
||||
```bash
|
||||
secret-tool store --label="alice_access" service solitaire_quest_server account alice_access <<< "bad"
|
||||
secret-tool store --label="alice_refresh" service solitaire_quest_server account alice_refresh <<< "bad"
|
||||
secret-tool store --label="alice_access" service ferrous_solitaire_server account alice_access <<< "bad"
|
||||
secret-tool store --label="alice_refresh" service ferrous_solitaire_server account alice_refresh <<< "bad"
|
||||
```
|
||||
|
||||
2. Launch the game and trigger a sync.
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Maintainer: funman300 <funman300@gmail.com>
|
||||
|
||||
pkgname=solitaire-quest-server
|
||||
pkgname=ferrous-solitaire-server
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc='Self-hosted sync server for Solitaire Quest (stats, achievements, leaderboards)'
|
||||
url='https://github.com/funman300/solitaire-quest'
|
||||
pkgdesc='Self-hosted sync server for Ferrous Solitaire (stats, achievements, leaderboards)'
|
||||
url='https://github.com/funman300/ferrous-solitaire'
|
||||
license=('MIT')
|
||||
arch=('x86_64')
|
||||
makedepends=('cargo' 'rust')
|
||||
@@ -12,12 +12,12 @@ depends=(
|
||||
'gcc-libs'
|
||||
'glibc'
|
||||
)
|
||||
backup=('etc/solitaire-quest-server/server.env')
|
||||
backup=('etc/ferrous-solitaire-server/server.env')
|
||||
|
||||
# Build from the local workspace (two levels above this PKGBUILD).
|
||||
_srcdir="$startdir/../.."
|
||||
source=(
|
||||
'solitaire-quest-server.service'
|
||||
'ferrous-solitaire-server.service'
|
||||
'server.env'
|
||||
)
|
||||
b2sums=('SKIP'
|
||||
@@ -49,12 +49,12 @@ package() {
|
||||
install -Dm0755 -t "$pkgdir/usr/bin/" "target/release/solitaire_server"
|
||||
|
||||
# systemd service
|
||||
install -Dm0644 "$srcdir/solitaire-quest-server.service" \
|
||||
"$pkgdir/usr/lib/systemd/system/solitaire-quest-server.service"
|
||||
install -Dm0644 "$srcdir/ferrous-solitaire-server.service" \
|
||||
"$pkgdir/usr/lib/systemd/system/ferrous-solitaire-server.service"
|
||||
|
||||
# Environment file (contains JWT_SECRET, DATABASE_URL, SERVER_PORT)
|
||||
install -Dm0640 "$srcdir/server.env" \
|
||||
"$pkgdir/etc/solitaire-quest-server/server.env"
|
||||
"$pkgdir/etc/ferrous-solitaire-server/server.env"
|
||||
|
||||
# License and docs
|
||||
install -Dm0644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE"
|
||||
@@ -0,0 +1,23 @@
|
||||
[Unit]
|
||||
Description=Ferrous Solitaire Sync Server
|
||||
Documentation=https://github.com/funman300/ferrous-solitaire/blob/main/README_SERVER.md
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ferrous-solitaire
|
||||
Group=ferrous-solitaire
|
||||
EnvironmentFile=/etc/ferrous-solitaire-server/server.env
|
||||
ExecStart=/usr/bin/solitaire_server
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Harden the service
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/ferrous-solitaire-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -1,10 +1,10 @@
|
||||
# Solitaire Quest Server — environment configuration
|
||||
# This file is installed to /etc/solitaire-quest-server/server.env (mode 0640).
|
||||
# Ferrous Solitaire Server — environment configuration
|
||||
# This file is installed to /etc/ferrous-solitaire-server/server.env (mode 0640).
|
||||
# Edit these values before starting the service.
|
||||
|
||||
# Path to the SQLite database file.
|
||||
# The directory must be writable by the solitaire-quest service user.
|
||||
DATABASE_URL=sqlite:///var/lib/solitaire-quest-server/solitaire.db
|
||||
# The directory must be writable by the ferrous-solitaire service user.
|
||||
DATABASE_URL=sqlite:///var/lib/ferrous-solitaire-server/solitaire.db
|
||||
|
||||
# HS256 signing secret for JWT tokens.
|
||||
# Generate a strong secret with: openssl rand -hex 32
|
||||
@@ -1,10 +1,10 @@
|
||||
# Maintainer: funman300 <funman300@gmail.com>
|
||||
|
||||
pkgname=solitaire-quest
|
||||
pkgname=ferrous-solitaire
|
||||
pkgver=0.1.0
|
||||
pkgrel=1
|
||||
pkgdesc='Cross-platform Klondike Solitaire with progression, achievements, and optional sync'
|
||||
url='https://github.com/funman300/solitaire-quest'
|
||||
url='https://github.com/funman300/ferrous-solitaire'
|
||||
license=('MIT')
|
||||
arch=('x86_64')
|
||||
makedepends=('cargo' 'rust')
|
||||
@@ -1,23 +0,0 @@
|
||||
[Unit]
|
||||
Description=Solitaire Quest Sync Server
|
||||
Documentation=https://github.com/funman300/solitaire-quest/blob/main/README_SERVER.md
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=solitaire-quest
|
||||
Group=solitaire-quest
|
||||
EnvironmentFile=/etc/solitaire-quest-server/server.env
|
||||
ExecStart=/usr/bin/solitaire_server
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
# Harden the service
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/lib/solitaire-quest-server
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -18,7 +18,7 @@
|
||||
# "arm64-v8a armeabi-v7a x86_64"). Reduce in CI to
|
||||
# fit the runner's disk budget — a full three-ABI
|
||||
# debug build can exceed 25 GB of target/ output.
|
||||
# APK_OUT Output APK path (default: target/$PROFILE/apk/solitaire-quest.apk)
|
||||
# APK_OUT Output APK path (default: target/$PROFILE/apk/ferrous-solitaire.apk)
|
||||
# KEYSTORE Path to keystore for signing (default: generates a debug keystore)
|
||||
# KEYSTORE_PASS Keystore password (default: "android" for the generated debug keystore)
|
||||
# KEY_ALIAS Key alias (default: "androiddebugkey")
|
||||
@@ -35,7 +35,7 @@ set -euo pipefail
|
||||
|
||||
PROFILE="${PROFILE:-debug}"
|
||||
ABIS="${ABIS:-arm64-v8a armeabi-v7a x86_64}"
|
||||
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/solitaire-quest.apk}"
|
||||
APK_OUT="${APK_OUT:-target/${PROFILE}/apk/ferrous-solitaire.apk}"
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
@@ -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
|
||||
|
||||
@@ -56,8 +56,8 @@ tiny-skia = { workspace = true }
|
||||
# already uses ships into the APK without copy-tree gymnastics.
|
||||
# `apk_name` keeps the output filename predictable across machines.
|
||||
[package.metadata.android]
|
||||
package = "com.solitairequest.app"
|
||||
apk_name = "solitaire-quest"
|
||||
package = "com.ferrousapp.solitaire"
|
||||
apk_name = "ferrous-solitaire"
|
||||
build_targets = ["aarch64-linux-android", "armv7-linux-androideabi", "x86_64-linux-android"]
|
||||
assets = "../assets"
|
||||
# Density-bucketed launcher icons. `aapt` processes `res/mipmap-*/` and
|
||||
|
||||
@@ -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.solitairequest.app"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0">
|
||||
package="com.ferrousapp.solitaire">
|
||||
|
||||
<uses-sdk
|
||||
android:minSdkVersion="26"
|
||||
|
||||
@@ -109,7 +109,7 @@ pub fn run() {
|
||||
title: "Ferrous Solitaire".into(),
|
||||
// X11/Wayland WM_CLASS so taskbar managers group
|
||||
// multiple windows of this app correctly.
|
||||
name: Some("solitaire-quest".into()),
|
||||
name: Some("ferrous-solitaire".into()),
|
||||
resolution: window_resolution,
|
||||
position: window_position,
|
||||
// AutoNoVsync prefers Mailbox (triple-buffered) and
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
pub use solitaire_sync::AchievementRecord;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const FILE_NAME: &str = "achievements.json";
|
||||
|
||||
/// Platform-specific default path for `achievements.json`.
|
||||
|
||||
@@ -19,7 +19,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::auth_tokens::TokenError;
|
||||
|
||||
const KEY_ALIAS: &str = "solitaire_quest_token_key";
|
||||
const KEY_ALIAS: &str = "ferrous_solitaire_token_key";
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct TokenBlob {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Secure storage for JWT access and refresh tokens using the OS keychain.
|
||||
//!
|
||||
//! Tokens are stored under service name `"solitaire_quest_server"` with entry
|
||||
//! Tokens are stored under service name `"ferrous_solitaire_server"` with entry
|
||||
//! keys `"{username}_access"` and `"{username}_refresh"`.
|
||||
//!
|
||||
//! On Linux this requires a running secret service (GNOME Keyring / KWallet).
|
||||
@@ -46,7 +46,7 @@ pub enum TokenError {
|
||||
|
||||
/// Service name used to namespace all keychain entries for this application.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
const SERVICE: &str = "solitaire_quest_server";
|
||||
const SERVICE: &str = "ferrous_solitaire_server";
|
||||
|
||||
/// Map a `keyring_core::Error` to the appropriate `TokenError`.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! The rest of `solitaire_data` (settings, stats, achievements,
|
||||
//! replays, progress, game state) and the engine's user-themes
|
||||
//! discovery all need a base path under which to nest
|
||||
//! `solitaire_quest/<file>`. On desktop the right answer is
|
||||
//! `ferrous_solitaire/<file>`. On desktop the right answer is
|
||||
//! `dirs::data_dir()` (which resolves to platform-appropriate
|
||||
//! locations: `~/.local/share` on Linux, `~/Library/Application
|
||||
//! Support` on macOS, `%APPDATA%` on Windows). On Android the
|
||||
@@ -12,9 +12,9 @@
|
||||
//!
|
||||
//! [`data_dir`] is a thin shim that returns the right base path
|
||||
//! per target. Callers continue to append
|
||||
//! `solitaire_quest/<file>` themselves, so the on-disk layout is
|
||||
//! `ferrous_solitaire/<file>` themselves, so the on-disk layout is
|
||||
//! identical across platforms (the per-app Android sandbox makes
|
||||
//! the extra `solitaire_quest/` segment harmless, and a `tar`
|
||||
//! the extra `ferrous_solitaire/` segment harmless, and a `tar`
|
||||
//! export from one platform deserialises cleanly on another).
|
||||
//!
|
||||
//! # Why hardcode on Android?
|
||||
@@ -24,7 +24,7 @@
|
||||
//! `AndroidApp` context through Bevy's startup hooks and a
|
||||
//! per-call JNI bridge — meaningfully more code than the
|
||||
//! sandbox-guaranteed `/data/data/<package>/files` path. The
|
||||
//! package name `com.solitairequest.app` is fixed at compile
|
||||
//! package name `com.ferrousapp.solitaire` is fixed at compile
|
||||
//! time in `solitaire_app/Cargo.toml`'s
|
||||
//! `[package.metadata.android]` block, so a hardcoded path is
|
||||
//! safe until that ever changes (at which point this constant
|
||||
@@ -40,14 +40,14 @@ use std::path::PathBuf;
|
||||
/// constant and the Cargo metadata together if the package id
|
||||
/// ever changes.
|
||||
#[cfg(target_os = "android")]
|
||||
const ANDROID_APP_FILES_DIR: &str = "/data/data/com.solitairequest.app/files";
|
||||
const ANDROID_APP_FILES_DIR: &str = "/data/data/com.ferrousapp.solitaire/files";
|
||||
|
||||
/// Returns the per-user data directory for the current target,
|
||||
/// or `None` if the platform doesn't expose one (rare; usually
|
||||
/// indicates a broken `$HOME` or `$XDG_*` configuration on a
|
||||
/// minimal Linux container).
|
||||
///
|
||||
/// Callers append `solitaire_quest/<file>` themselves. See the
|
||||
/// Callers append `ferrous_solitaire/<file>` themselves. See the
|
||||
/// module-level doc comment for the per-platform behaviour and
|
||||
/// why Android uses a hardcoded path.
|
||||
pub fn data_dir() -> Option<PathBuf> {
|
||||
@@ -87,6 +87,6 @@ mod tests {
|
||||
#[test]
|
||||
fn data_dir_returns_sandbox_path_on_android() {
|
||||
let dir = data_dir().expect("android must report a data dir");
|
||||
assert_eq!(dir, PathBuf::from("/data/data/com.solitairequest.app/files"));
|
||||
assert_eq!(dir, PathBuf::from("/data/data/com.ferrousapp.solitaire/files"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ use chrono::{Datelike, NaiveDate};
|
||||
pub use solitaire_sync::progress::level_for_xp;
|
||||
pub use solitaire_sync::PlayerProgress;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const FILE_NAME: &str = "progress.json";
|
||||
|
||||
/// Deterministic seed derived from a date, identical for all players globally.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! Win-game replay recording + storage.
|
||||
//!
|
||||
//! When a player wins, the engine freezes the in-memory recording into a
|
||||
//! [`Replay`] and persists it to `<data_dir>/solitaire_quest/latest_replay.json`
|
||||
//! [`Replay`] and persists it to `<data_dir>/ferrous_solitaire/latest_replay.json`
|
||||
//! via [`save_latest_replay_to`]. The Stats screen offers a "Watch replay"
|
||||
//! action that loads it via [`load_latest_replay_from`] so the player can
|
||||
//! revisit (or, in a future build, watch the engine re-execute) the path
|
||||
@@ -29,7 +29,7 @@ use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DrawMode, GameMode};
|
||||
use solitaire_core::pile::PileType;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const LATEST_REPLAY_FILE_NAME: &str = "latest_replay.json";
|
||||
const REPLAY_HISTORY_FILE_NAME: &str = "replays.json";
|
||||
|
||||
@@ -221,7 +221,7 @@ impl Replay {
|
||||
/// Rolling history of the player's most recent winning replays.
|
||||
///
|
||||
/// Stored as a single JSON file at
|
||||
/// `<data_dir>/solitaire_quest/replays.json` (see
|
||||
/// `<data_dir>/ferrous_solitaire/replays.json` (see
|
||||
/// [`replay_history_path`]). Capped at [`REPLAY_HISTORY_CAP`] entries —
|
||||
/// when [`append_replay_to_history`] pushes past the cap, the oldest
|
||||
/// entry is dropped so the file never grows unbounded.
|
||||
|
||||
@@ -11,7 +11,7 @@ use std::path::{Path, PathBuf};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use solitaire_core::game_state::{DifficultyLevel, DrawMode};
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||
|
||||
/// Animation playback speed for card transitions.
|
||||
|
||||
@@ -13,7 +13,7 @@ use solitaire_core::game_state::{GameState, GAME_STATE_SCHEMA_VERSION};
|
||||
|
||||
use crate::stats::StatsSnapshot;
|
||||
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
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";
|
||||
|
||||
@@ -29,7 +29,7 @@ static USER_THEME_DIR_OVERRIDE: OnceLock<PathBuf> = OnceLock::new();
|
||||
/// Sub-folder under `dirs::data_dir()` where the project keeps every
|
||||
/// per-user file. Matches the existing convention used by
|
||||
/// `solitaire_data` for `settings.json`, `stats.json`, etc.
|
||||
const APP_DIR_NAME: &str = "solitaire_quest";
|
||||
const APP_DIR_NAME: &str = "ferrous_solitaire";
|
||||
|
||||
/// Sub-folder under [`APP_DIR_NAME`] dedicated to user themes.
|
||||
const THEME_DIR_NAME: &str = "themes";
|
||||
@@ -97,19 +97,19 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn user_theme_dir_for_appends_solitaire_quest_themes() {
|
||||
fn user_theme_dir_for_appends_ferrous_solitaire_themes() {
|
||||
let dir = user_theme_dir_for(PathBuf::from("/tmp/data"));
|
||||
assert_eq!(
|
||||
dir,
|
||||
PathBuf::from("/tmp/data/solitaire_quest/themes"),
|
||||
"user dir must nest under solitaire_quest/themes"
|
||||
PathBuf::from("/tmp/data/ferrous_solitaire/themes"),
|
||||
"user dir must nest under ferrous_solitaire/themes"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_theme_dir_for_handles_empty_root() {
|
||||
let dir = user_theme_dir_for(PathBuf::new());
|
||||
assert_eq!(dir, PathBuf::from("solitaire_quest/themes"));
|
||||
assert_eq!(dir, PathBuf::from("ferrous_solitaire/themes"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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,
|
||||
@@ -429,6 +456,9 @@ impl Plugin for CardPlugin {
|
||||
snap_cards_on_window_resize.after(collect_resize_events),
|
||||
),
|
||||
);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.add_systems(Update, resize_android_corner_labels);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -754,15 +784,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 +808,12 @@ 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -831,16 +867,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 +891,12 @@ 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn label_for(card: &Card) -> String {
|
||||
@@ -928,6 +969,87 @@ 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.
|
||||
#[cfg(target_os = "android")]
|
||||
fn add_android_corner_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
card: &Card,
|
||||
card_size: Vec2,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
) {
|
||||
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.
|
||||
parent.spawn((
|
||||
AndroidCornerLabel,
|
||||
CardLabel,
|
||||
Text2d::new(mobile_label_for(card)),
|
||||
TextFont { 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1836,6 +1958,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.
|
||||
|
||||
@@ -1302,7 +1302,7 @@ mod tests {
|
||||
|
||||
/// Build a minimal headless `App` with just `GamePlugin` installed.
|
||||
/// Disables persistence and overrides the seed so tests are deterministic
|
||||
/// and don't touch `~/.local/share/solitaire_quest/game_state.json`.
|
||||
/// and don't touch `~/.local/share/ferrous_solitaire/game_state.json`.
|
||||
fn test_app(seed: u64) -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins).add_plugins(GamePlugin);
|
||||
@@ -1316,7 +1316,7 @@ mod tests {
|
||||
// can't leak into per-test world state and trip the
|
||||
// `pending.0.is_some()` guard in `auto_save_game_state` /
|
||||
// `save_game_state_on_exit`. Without this clear, an
|
||||
// unrelated `~/.local/share/solitaire_quest/game_state.json`
|
||||
// unrelated `~/.local/share/ferrous_solitaire/game_state.json`
|
||||
// would silently disable the auto-save path under test.
|
||||
app.insert_resource(PendingRestoredGame(None));
|
||||
// Override the system-time seed with a known value.
|
||||
|
||||
@@ -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, SafeAreaInsets};
|
||||
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
|
||||
@@ -395,13 +407,14 @@ 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)
|
||||
@@ -446,7 +459,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>()
|
||||
@@ -684,6 +702,135 @@ 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>>,
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
avatar: Option<Res<AvatarResource>>,
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
const SIZE: f32 = 32.0;
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let id = commands
|
||||
.spawn((
|
||||
HudAvatar,
|
||||
Button,
|
||||
Tooltip::new("Your profile — tap to open."),
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
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
|
||||
@@ -697,23 +844,19 @@ fn spawn_action_buttons(
|
||||
insets: Option<Res<SafeAreaInsets>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
let bottom_inset = insets.as_deref().copied().unwrap_or_default().bottom;
|
||||
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 */ "!", // ! attention/alert — semantically: "look here"
|
||||
/* 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.
|
||||
// `bottom` is set to `bottom_inset` initially; `SafeAreaAnchoredBottom` keeps
|
||||
// it correct as Android insets arrive in later frames.
|
||||
commands
|
||||
.spawn((
|
||||
Node {
|
||||
position_type: PositionType::Absolute,
|
||||
right: VAL_SPACE_3,
|
||||
top: Val::Px(SPACE_2 + top_inset),
|
||||
bottom: Val::Px(bottom_inset),
|
||||
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),
|
||||
@@ -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),
|
||||
@@ -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.
|
||||
@@ -2280,15 +2447,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 +2459,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 +2483,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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -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 {})",
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -552,7 +552,7 @@ mod tests {
|
||||
.add_plugins(GamePlugin::headless())
|
||||
.add_plugins(ReplayPlaybackPlugin);
|
||||
// Disable game-state persistence so tests don't touch the
|
||||
// real ~/.local/share/solitaire_quest/game_state.json.
|
||||
// real ~/.local/share/ferrous_solitaire/game_state.json.
|
||||
app.insert_resource(crate::game_plugin::GameStatePath(None));
|
||||
app.insert_resource(crate::game_plugin::ReplayPath(None));
|
||||
// Tick once so any startup systems flush before the first
|
||||
|
||||
@@ -51,12 +51,25 @@ 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));
|
||||
.add_systems(
|
||||
Update,
|
||||
(apply_safe_area_anchors, apply_safe_area_bottom_anchors, apply_safe_area_to_modal_scrims),
|
||||
);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.add_systems(Update, android::refresh_insets);
|
||||
@@ -89,6 +102,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.
|
||||
///
|
||||
|
||||
@@ -64,7 +64,7 @@ pub struct StatsCell;
|
||||
/// Resource holding the rolling [`ReplayHistory`] of recent winning
|
||||
/// replays.
|
||||
///
|
||||
/// Populated from `<data_dir>/solitaire_quest/replays.json` at startup
|
||||
/// Populated from `<data_dir>/ferrous_solitaire/replays.json` at startup
|
||||
/// and refreshed in-place whenever the engine writes a new winning
|
||||
/// replay so the Stats overlay's selector always reflects the current
|
||||
/// on-disk history.
|
||||
@@ -166,7 +166,7 @@ impl Default for StatsPlugin {
|
||||
|
||||
impl StatsPlugin {
|
||||
/// Plugin configured with no persistence. Use in tests and headless apps
|
||||
/// where touching `~/.local/share/solitaire_quest/stats.json` would be
|
||||
/// where touching `~/.local/share/ferrous_solitaire/stats.json` would be
|
||||
/// incorrect.
|
||||
pub fn headless() -> Self {
|
||||
Self { storage_path: None }
|
||||
|
||||
@@ -307,7 +307,7 @@ mod tests {
|
||||
.add_plugins(TimeAttackPlugin);
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
// Disable session persistence — tests must not touch the real
|
||||
// ~/.local/share/solitaire_quest/time_attack_session.json.
|
||||
// ~/.local/share/ferrous_solitaire/time_attack_session.json.
|
||||
app.insert_resource(TimeAttackSessionPath(None));
|
||||
// The plugin's startup-load hook may have populated TimeAttackResource
|
||||
// from a real on-disk session. Reset it so each test starts inactive.
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: solitaire_server/Dockerfile
|
||||
image: solitaire-quest-server:latest
|
||||
image: ferrous-solitaire-server:latest
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${SERVER_PORT:-8080}:8080"
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user