Compare commits
23 Commits
a9285ccb41
...
v0.34.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 04e99a8d24 | |||
| 980312c22c | |||
| 9623bdeede | |||
| 4df13695fc | |||
| df22338c8a | |||
| 7f450aab17 | |||
| d8f67dcad3 | |||
| ccb77f76b8 | |||
| da54faf8e2 | |||
| f3d01b5890 | |||
| faefca0445 | |||
| 24d83c9ae3 | |||
| 9d4234cded | |||
| e48f652454 | |||
| c24c7f6b61 | |||
| 686f57252c | |||
| 059af2ac28 | |||
| 858012d926 | |||
| f6be961419 | |||
| 8a145154db | |||
| e17667d034 | |||
| 005e29d1ab | |||
| 9d3cc94831 |
@@ -6,10 +6,6 @@ on:
|
||||
- 'v*'
|
||||
|
||||
env:
|
||||
ANDROID_HOME: /opt/android-sdk
|
||||
NDK_VERSION: 30.0.14904198
|
||||
BUILD_TOOLS_VERSION: "36.1.0"
|
||||
PLATFORM: android-34
|
||||
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
||||
GITEA_URL: https://git.aleshym.co
|
||||
REPO: funman300/Ferrous-Solitaire
|
||||
@@ -17,75 +13,56 @@ env:
|
||||
jobs:
|
||||
build-apk:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: git.aleshym.co/funman300/android-builder:latest
|
||||
credentials:
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
|
||||
- name: Cache Android SDK
|
||||
uses: actions/cache@v4
|
||||
id: android-cache
|
||||
with:
|
||||
path: /opt/android-sdk
|
||||
key: android-sdk-${{ env.NDK_VERSION }}-${{ env.BUILD_TOOLS_VERSION }}
|
||||
|
||||
- name: Install Android SDK + NDK
|
||||
if: steps.android-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
mkdir -p $ANDROID_HOME/cmdline-tools
|
||||
wget -q https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip \
|
||||
-O /tmp/cmdtools.zip
|
||||
unzip -q /tmp/cmdtools.zip -d $ANDROID_HOME/cmdline-tools
|
||||
mv $ANDROID_HOME/cmdline-tools/cmdline-tools $ANDROID_HOME/cmdline-tools/latest
|
||||
yes | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager --licenses >/dev/null || true
|
||||
$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
|
||||
"ndk;$NDK_VERSION" \
|
||||
"build-tools;$BUILD_TOOLS_VERSION" \
|
||||
"platforms;$PLATFORM"
|
||||
|
||||
- name: Set up Rust (stable + aarch64-android)
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: aarch64-linux-android
|
||||
|
||||
- name: Cache Cargo
|
||||
- name: Cache Cargo registry
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index
|
||||
~/.cargo/registry/cache
|
||||
~/.cargo/git/db
|
||||
~/.cargo/bin/cargo-ndk
|
||||
target/aarch64-linux-android
|
||||
key: cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-android-
|
||||
/usr/local/cargo/registry/index
|
||||
/usr/local/cargo/registry/cache
|
||||
/usr/local/cargo/git/db
|
||||
key: cargo-registry-android-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: cargo-registry-android-
|
||||
|
||||
- name: Install cargo-ndk
|
||||
run: cargo-ndk --version 2>/dev/null || cargo install cargo-ndk --version 4.1.2 --locked
|
||||
- name: Cache sccache
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /root/.cache/sccache
|
||||
key: sccache-android-aarch64-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: sccache-android-aarch64-
|
||||
|
||||
- name: Get tag name
|
||||
id: tag
|
||||
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Decode release keystore
|
||||
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
||||
|
||||
- name: Build release APK
|
||||
env:
|
||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/${{ env.NDK_VERSION }}
|
||||
PROFILE: release
|
||||
ABIS: arm64-v8a
|
||||
KEYSTORE: ./release.jks
|
||||
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
||||
KEY_ALIAS: release
|
||||
KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
||||
VERSION_NAME: ${{ steps.tag.outputs.name }}
|
||||
RUSTC_WRAPPER: sccache
|
||||
SCCACHE_DIR: /root/.cache/sccache
|
||||
run: bash scripts/build_android_apk.sh
|
||||
|
||||
- name: Get tag name
|
||||
id: tag
|
||||
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
||||
- name: sccache stats
|
||||
if: always()
|
||||
run: sccache --show-stats
|
||||
|
||||
- name: Create or get Gitea release
|
||||
id: release
|
||||
@@ -94,7 +71,6 @@ jobs:
|
||||
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
|
||||
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
|
||||
|
||||
# Re-use an existing release for this tag (e.g. created manually).
|
||||
ID=$(curl -sf -H "$AUTH" "$BASE/releases/tags/$TAG" \
|
||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
|
||||
2>/dev/null || true)
|
||||
@@ -116,7 +92,18 @@ jobs:
|
||||
|
||||
- name: Upload APK to release
|
||||
run: |
|
||||
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
|
||||
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
|
||||
RELEASE_ID="${{ steps.release.outputs.id }}"
|
||||
|
||||
# Remove any existing APK assets so re-runs don't accumulate duplicates.
|
||||
curl -sf -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets" \
|
||||
| python3 -c "import sys,json; [print(a['id']) for a in json.load(sys.stdin) if a['name'].endswith('.apk')]" \
|
||||
| while read AID; do
|
||||
curl -sf -X DELETE -H "$AUTH" "$BASE/releases/$RELEASE_ID/assets/$AID"
|
||||
done
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
|
||||
-H "$AUTH" \
|
||||
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
|
||||
"${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}/releases/${{ steps.release.outputs.id }}/assets"
|
||||
"$BASE/releases/$RELEASE_ID/assets"
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Build Android Builder Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- 'docker/android-builder.Dockerfile'
|
||||
- '.gitea/workflows/builder-image.yml'
|
||||
|
||||
env:
|
||||
IMAGE: git.aleshym.co/funman300/android-builder
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Log in to Gitea registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: git.aleshym.co
|
||||
username: ${{ gitea.actor }}
|
||||
password: ${{ secrets.CI_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver-opts: network=host
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/android-builder.Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.IMAGE }}:latest
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Install kustomize
|
||||
run: |
|
||||
curl -sL "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
curl -sL https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv5.4.3/kustomize_v5.4.3_linux_amd64.tar.gz | tar xz
|
||||
sudo mv kustomize /usr/local/bin/kustomize
|
||||
|
||||
- name: Pin image tag in deploy manifests
|
||||
|
||||
@@ -6,6 +6,75 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.33.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Face-down cards render as tiny red squares (startup ordering bug)**. The
|
||||
`load_initial_theme` system fell back to `"dark"` when `SettingsResource` was
|
||||
not yet available at `Startup`, which happens on every fresh run before the
|
||||
settings file is read. The dark theme's near-black card back (#151515) renders
|
||||
as fully-off pixels on AMOLED screens, leaving only a 24×32 px red badge
|
||||
visible. Changed the fallback to `"classic"` so startup behaviour matches the
|
||||
`default_theme_id()` set in v0.31.0. Cascade-collapse and top-row legibility
|
||||
issues were visual consequences of the same invisible-card-back problem, not
|
||||
separate layout bugs.
|
||||
|
||||
## [0.32.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stock-count badge overlaps waste pile on Android** (Bug 1). The badge was
|
||||
centred 12 px inward from the stock pile's right edge, but its half-width of
|
||||
17 px pushed it 5 px past the edge. On Android (`H_GAP_DIVISOR = 32`) the
|
||||
inter-pile gap is only ~4 px, so the badge's top-right corner covered the
|
||||
left edge of the adjacent waste card at `Z_STOCK_BADGE = 30` (above the
|
||||
card's Z ≈ 1). Fixed by moving the inset to 20 px so the badge right edge
|
||||
sits 3 px inside the stock card on every device.
|
||||
- **Oversized grey header bar** (Bug 2). The top HUD band was a full-width
|
||||
`Node` with an opaque dark-grey `BackgroundColor` sized to `HUD_BAND_HEIGHT`
|
||||
(64 px desktop / 80 px Android). Typical gameplay only shows one tier of
|
||||
score text (~30 px), leaving a large empty grey block. Removed the
|
||||
`BackgroundColor` from the band entity; the green felt now shows through and
|
||||
only the score text and avatar button are visible in the header area.
|
||||
|
||||
## [0.31.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Face-down cards rendered as solid red squares on AMOLED phones** (Bug 1).
|
||||
The dark theme's card back (`back.svg`) uses a near-black background
|
||||
(`#151515`) which AMOLED screens render as fully-off pixels, leaving only a
|
||||
tiny `#a54242` red badge visible — exactly what was reported. Fixed by
|
||||
changing the fresh-install default theme from "dark" to "classic" (white
|
||||
background with navy diamond pattern, clearly readable on all display types).
|
||||
Also corrected stale asset paths in `load_card_images` (`cards/backs/back_N`
|
||||
→ `cards/backs/classic/back_N`, `cards/faces/XY` → `cards/faces/classic/XY`)
|
||||
so the PNG fallback loads correctly when the embedded theme hasn't arrived yet.
|
||||
|
||||
## [0.30.0] — 2026-05-16
|
||||
|
||||
### Changed
|
||||
|
||||
- **Tableau card spacing tightened.** Face-up card fan reduced from 25% to 18%
|
||||
of card height; face-down from 20% to 14%. Cards on tableau piles sit closer
|
||||
together while still showing enough of each card to read the pile depth.
|
||||
|
||||
## [0.29.0] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
|
||||
- **APK versionCode hardcoded to 1** (`AndroidManifest.xml`, `build_android_apk.sh`).
|
||||
Every release shipped with `versionCode="1"` / `versionName="1.0"`, so Android
|
||||
silently refused upgrades and Obtainium permanently showed a false update
|
||||
notification. The CI now derives the version code from the release tag
|
||||
(e.g. v0.29.0 → 2900) and stamps it into the APK via `aapt2 link
|
||||
--version-code / --version-name`.
|
||||
- **CI kustomize install flaky** (`.gitea/workflows/docker-build.yml`).
|
||||
The `curl | bash install_kustomize.sh` pattern hit GitHub API rate limits
|
||||
on the shared runner IP, causing a `tar: no such file` failure. Replaced
|
||||
with a direct pinned tarball download (kustomize v5.4.3).
|
||||
|
||||
## [0.28.0] — 2026-05-14
|
||||
|
||||
### Changed
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.0 KiB After Width: | Height: | Size: 6.0 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -20,4 +20,4 @@ resources:
|
||||
images:
|
||||
- name: solitaire-server
|
||||
newName: git.aleshym.co/funman300/solitaire-server
|
||||
newTag: d761a150
|
||||
newTag: 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
|
||||
@@ -75,12 +75,27 @@ if [ -d "$RES_DIR" ]; then
|
||||
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
|
||||
fi
|
||||
|
||||
# Derive versionCode/versionName from VERSION_NAME env var (e.g. "v0.28.0" → code 2800, name "0.28.0").
|
||||
# AndroidManifest.xml intentionally has no versionCode/versionName — aapt2's --version-* flags only
|
||||
# inject when absent, so the manifest must be clean for CI injection to work. Local debug builds
|
||||
# fall back to code=1 / name="0.0.0-dev".
|
||||
if [ -n "${VERSION_NAME:-}" ]; then
|
||||
VN="${VERSION_NAME#v}"
|
||||
IFS='.' read -r _MAJ _MIN _PAT <<< "$VN"
|
||||
VERSION_CODE=$(( ${_MAJ:-0} * 10000 + ${_MIN:-0} * 100 + ${_PAT:-0} ))
|
||||
else
|
||||
VERSION_CODE=1
|
||||
VERSION_NAME="0.0.0-dev"
|
||||
fi
|
||||
|
||||
LINK_ARGS=(
|
||||
link
|
||||
-o "$STAGING/app-unsigned.apk"
|
||||
-I "$PLATFORM_JAR"
|
||||
--manifest "$MANIFEST"
|
||||
)
|
||||
[ -n "$VERSION_CODE" ] && LINK_ARGS+=( --version-code "$VERSION_CODE" )
|
||||
[ -n "${VERSION_NAME:-}" ] && LINK_ARGS+=( --version-name "${VERSION_NAME#v}" )
|
||||
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
|
||||
# Add compiled resources if any
|
||||
shopt -s nullglob
|
||||
|
||||
@@ -13,9 +13,7 @@
|
||||
shared object name without the `lib` prefix or `.so` suffix.
|
||||
-->
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="com.ferrousapp.solitaire"
|
||||
android:versionCode="1"
|
||||
android:versionName="1.0">
|
||||
package="com.ferrousapp.solitaire">
|
||||
|
||||
<uses-sdk
|
||||
android:minSdkVersion="26"
|
||||
|
||||
@@ -275,7 +275,7 @@ fn default_music_volume() -> f32 {
|
||||
}
|
||||
|
||||
fn default_theme_id() -> String {
|
||||
"dark".to_string()
|
||||
"classic".to_string()
|
||||
}
|
||||
|
||||
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||
@@ -402,11 +402,10 @@ impl Settings {
|
||||
/// their respective ranges after deserialization or hand-editing of
|
||||
/// `settings.json`.
|
||||
pub fn sanitized(self) -> Self {
|
||||
// Migrate stale theme IDs: "default" was removed when the theme was
|
||||
// renamed to "dark"; "classic" was briefly the default before "dark"
|
||||
// was restored as the shipped default.
|
||||
// Migrate stale theme IDs: "default" was the original name before it
|
||||
// was renamed to "dark".
|
||||
let selected_theme_id = match self.selected_theme_id.as_str() {
|
||||
"default" | "classic" => "dark".to_string(),
|
||||
"default" => "dark".to_string(),
|
||||
_ => self.selected_theme_id,
|
||||
};
|
||||
Self {
|
||||
|
||||
@@ -462,6 +462,49 @@ impl Plugin for CardPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the relative asset path for a card face PNG.
|
||||
///
|
||||
/// The path format is `cards/faces/classic/{RANK}{SUIT}.png`, e.g. `QS.png`
|
||||
/// for the Queen of Spades. Both `load_card_images` and the unit tests use
|
||||
/// this function so the filename formula is tested in isolation from the
|
||||
/// asset-loading machinery.
|
||||
///
|
||||
/// Note: this function verifies only the **code-side mapping**. If the PNG
|
||||
/// file at the returned path contains wrong artwork (e.g. `QS.png` has a
|
||||
/// diamond watermark baked in), that is an **asset content bug** and must be
|
||||
/// fixed by replacing the file — no code change can correct it.
|
||||
fn card_face_asset_path(rank: Rank, suit: Suit) -> String {
|
||||
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
||||
const RANK_STRS: [&str; 13] = [
|
||||
"A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K",
|
||||
];
|
||||
let suit_idx = match suit {
|
||||
Suit::Clubs => 0,
|
||||
Suit::Diamonds => 1,
|
||||
Suit::Hearts => 2,
|
||||
Suit::Spades => 3,
|
||||
};
|
||||
let rank_idx = match rank {
|
||||
Rank::Ace => 0,
|
||||
Rank::Two => 1,
|
||||
Rank::Three => 2,
|
||||
Rank::Four => 3,
|
||||
Rank::Five => 4,
|
||||
Rank::Six => 5,
|
||||
Rank::Seven => 6,
|
||||
Rank::Eight => 7,
|
||||
Rank::Nine => 8,
|
||||
Rank::Ten => 9,
|
||||
Rank::Jack => 10,
|
||||
Rank::Queen => 11,
|
||||
Rank::King => 12,
|
||||
};
|
||||
format!(
|
||||
"cards/faces/classic/{}{}.png",
|
||||
RANK_STRS[rank_idx], SUIT_CHARS[suit_idx]
|
||||
)
|
||||
}
|
||||
|
||||
/// Loads card face and back PNGs at startup via [`AssetServer`] and inserts
|
||||
/// [`CardImageSet`].
|
||||
///
|
||||
@@ -476,21 +519,19 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
|
||||
return;
|
||||
};
|
||||
|
||||
// Suit index: Clubs=0, Diamonds=1, Hearts=2, Spades=3
|
||||
const SUIT_CHARS: [&str; 4] = ["C", "D", "H", "S"];
|
||||
// Rank index: Ace=0 … King=12
|
||||
const RANK_STRS: [&str; 13] = ["A", "2", "3", "4", "5", "6", "7", "8", "9", "10", "J", "Q", "K"];
|
||||
const SUITS: [Suit; 4] = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
const RANKS: [Rank; 13] = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six, Rank::Seven,
|
||||
Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
|
||||
];
|
||||
|
||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
|
||||
std::array::from_fn(|rank| {
|
||||
asset_server.load(format!(
|
||||
"cards/faces/{}{}.png",
|
||||
RANK_STRS[rank], SUIT_CHARS[suit]
|
||||
))
|
||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|si| {
|
||||
std::array::from_fn(|ri| {
|
||||
asset_server.load(card_face_asset_path(RANKS[ri], SUITS[si]))
|
||||
})
|
||||
});
|
||||
let backs = std::array::from_fn(|i| {
|
||||
asset_server.load(format!("cards/backs/back_{i}.png"))
|
||||
asset_server.load(format!("cards/backs/classic/back_{i}.png"))
|
||||
});
|
||||
commands.insert_resource(CardImageSet {
|
||||
faces,
|
||||
@@ -584,6 +625,7 @@ fn resync_cards_on_settings_change(
|
||||
/// Render the initial deal. Runs in `PostStartup`, so all `Startup` systems
|
||||
/// (including `TablePlugin::setup_table` which inserts `LayoutResource`)
|
||||
/// have already completed.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_cards_startup(
|
||||
commands: Commands,
|
||||
game: Res<GameStateResource>,
|
||||
@@ -592,6 +634,7 @@ fn sync_cards_startup(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if let Some(layout) = layout {
|
||||
let slide_secs = slide_dur.map_or(0.15, |d| d.slide_secs);
|
||||
@@ -599,7 +642,8 @@ fn sync_cards_startup(
|
||||
let back_colour = card_back_colour(selected_back);
|
||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
|
||||
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -613,6 +657,7 @@ fn sync_cards_on_change(
|
||||
settings: Option<Res<SettingsResource>>,
|
||||
entities: Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<Res<CardImageSet>>,
|
||||
font_res: Option<Res<FontResource>>,
|
||||
) {
|
||||
if events.read().next().is_none() {
|
||||
return;
|
||||
@@ -623,7 +668,8 @@ fn sync_cards_on_change(
|
||||
let back_colour = card_back_colour(selected_back);
|
||||
let color_blind = settings.as_ref().is_some_and(|s| s.0.color_blind_mode);
|
||||
let high_contrast = settings.as_ref().is_some_and(|s| s.0.high_contrast_mode);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back);
|
||||
let font_handle = font_res.as_ref().map(|r| &r.0);
|
||||
sync_cards(commands, &game.0, &layout.0, slide_secs, back_colour, color_blind, high_contrast, &entities, card_images.as_deref(), selected_back, font_handle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -639,6 +685,7 @@ fn sync_cards(
|
||||
entities: &Query<(Entity, &CardEntity, &Transform, Option<&CardAnimation>)>,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
let positions = card_positions(game, layout);
|
||||
|
||||
@@ -668,10 +715,10 @@ fn sync_cards(
|
||||
Some(&(entity, cur, has_anim)) => {
|
||||
update_card_entity(
|
||||
&mut commands, entity, card, position, z, layout,
|
||||
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back,
|
||||
slide_secs, back_colour, color_blind, high_contrast, cur, has_anim, card_images, selected_back, font_handle,
|
||||
)
|
||||
}
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back),
|
||||
None => spawn_card_entity(&mut commands, card, position, z, layout, back_colour, color_blind, high_contrast, card_images, selected_back, font_handle),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -695,6 +742,19 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
PileType::Tableau(6),
|
||||
];
|
||||
|
||||
// Compute the Draw-Three waste fan step proportional to the column spacing
|
||||
// (waste_x − stock_x = card_width + h_gap) rather than a fixed fraction of
|
||||
// card_width. On desktop (H_GAP_DIVISOR=4) col_step = 1.25×cw and
|
||||
// 0.224 × 1.25 = 0.28 — identical to the previous constant. On Android
|
||||
// (H_GAP_DIVISOR=32) col_step ≈ 1.031×cw so fan_step ≈ 0.231×cw, keeping
|
||||
// the top fanned card's centre within the waste column's own horizontal
|
||||
// footprint instead of spilling into the adjacent gap.
|
||||
let waste_fan_step = {
|
||||
let s = layout.pile_positions.get(&PileType::Stock).copied().unwrap_or_default();
|
||||
let w = layout.pile_positions.get(&PileType::Waste).copied().unwrap_or_default();
|
||||
(w.x - s.x).abs() * 0.224
|
||||
};
|
||||
|
||||
for pile_type in piles {
|
||||
let Some(base) = layout.pile_positions.get(&pile_type) else {
|
||||
continue;
|
||||
@@ -736,7 +796,7 @@ fn card_positions<'a>(game: &'a GameState, layout: &Layout) -> Vec<(&'a Card, Ve
|
||||
// normally — no card is hidden, so the shift is 0.
|
||||
let visible = 3_usize;
|
||||
let hidden = rendered_len.saturating_sub(visible);
|
||||
slot.saturating_sub(hidden) as f32 * layout.card_size.x * 0.28
|
||||
slot.saturating_sub(hidden) as f32 * waste_fan_step
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -768,6 +828,7 @@ fn spawn_card_entity(
|
||||
high_contrast: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
let sprite = card_sprite(card, layout.card_size, back_colour, card_images, selected_back);
|
||||
|
||||
@@ -811,9 +872,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);
|
||||
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||
});
|
||||
}
|
||||
// Suppress unused-variable warning when not building for Android.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let _ = font_handle;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -832,6 +896,7 @@ fn update_card_entity(
|
||||
has_card_animation: bool,
|
||||
card_images: Option<&CardImageSet>,
|
||||
selected_back: usize,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
let target = Vec3::new(pos.x, pos.y, z);
|
||||
|
||||
@@ -894,9 +959,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);
|
||||
add_android_corner_label(b, card, layout.card_size, color_blind, high_contrast, font_handle);
|
||||
});
|
||||
}
|
||||
// Suppress unused-variable warning when not building for Android.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let _ = font_handle;
|
||||
}
|
||||
|
||||
fn label_for(card: &Card) -> String {
|
||||
@@ -1000,6 +1068,13 @@ fn mobile_label_for(card: &Card) -> String {
|
||||
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||
/// face-up cards. The background sprite covers the card art's own small
|
||||
/// corner text so only the large overlay is visible.
|
||||
/// Spawns the [`AndroidCornerLabel`] + [`AndroidCornerBg`] children on
|
||||
/// face-up cards using FiraMono (passed via `font_handle`) so that the
|
||||
/// suit Unicode glyphs U+2660–U+2666 render correctly. Without an explicit
|
||||
/// font handle Bevy falls back to its built-in face which does not include
|
||||
/// those glyphs, causing a coloured missing-glyph rectangle to appear in
|
||||
/// the text colour — the root cause of the "red square on face-down cards"
|
||||
/// visual bug (the box bleeds through near the card edge at z=0.02).
|
||||
#[cfg(target_os = "android")]
|
||||
fn add_android_corner_label(
|
||||
parent: &mut ChildSpawnerCommands,
|
||||
@@ -1007,6 +1082,7 @@ fn add_android_corner_label(
|
||||
card_size: Vec2,
|
||||
color_blind: bool,
|
||||
high_contrast: bool,
|
||||
font_handle: Option<&Handle<Font>>,
|
||||
) {
|
||||
if !card.face_up {
|
||||
return;
|
||||
@@ -1034,12 +1110,18 @@ fn add_android_corner_label(
|
||||
),
|
||||
));
|
||||
|
||||
// Large rank+suit text drawn on top of the background.
|
||||
// Large rank+suit text drawn on top of the background. FiraMono must be
|
||||
// wired here explicitly — the suit glyphs (U+2660–U+2666) are not in
|
||||
// Bevy's built-in font and render as a coloured rectangle without it.
|
||||
parent.spawn((
|
||||
AndroidCornerLabel,
|
||||
CardLabel,
|
||||
Text2d::new(mobile_label_for(card)),
|
||||
TextFont { font_size, ..default() },
|
||||
TextFont {
|
||||
font: font_handle.cloned().unwrap_or_default(),
|
||||
font_size,
|
||||
..default()
|
||||
},
|
||||
TextColor(text_colour(card, color_blind, high_contrast)),
|
||||
Anchor::TOP_LEFT,
|
||||
Transform::from_xyz(
|
||||
@@ -1614,10 +1696,11 @@ fn update_stock_empty_indicator(
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
|
||||
/// the centre of the count badge. A small inward offset keeps the chip from
|
||||
/// drifting half-off the card while still reading as "attached" to the
|
||||
/// corner.
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
||||
/// the centre of the count badge. Must satisfy `|x| >= STOCK_BADGE_SIZE.x / 2`
|
||||
/// so the badge right edge stays inside the stock pile and never overlaps the
|
||||
/// adjacent waste pile — critical on Android where `H_GAP_DIVISOR = 32` gives
|
||||
/// an inter-pile gap of only ~4 px.
|
||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-20.0, -8.0);
|
||||
|
||||
/// Width / height of the badge background sprite, in world pixels. Sized so
|
||||
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||
@@ -3166,4 +3249,230 @@ mod tests {
|
||||
assert!((highlight.blue - success.blue).abs() < 1e-6);
|
||||
assert!((highlight.alpha - 0.6).abs() < 1e-6);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bug #1 — CardImageSet key lookup (code-side mapping)
|
||||
//
|
||||
// These tests verify that every (Rank, Suit) pair produces the expected
|
||||
// filename via `card_face_asset_path`. They can only detect *code-side*
|
||||
// mapping bugs (e.g. a suit index mismatch). They do NOT inspect pixel
|
||||
// data — if `QS.png` contains a diamond watermark that is an *asset
|
||||
// content* bug that requires replacing the PNG file.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_queen_of_spades_is_qs_png() {
|
||||
assert_eq!(
|
||||
card_face_asset_path(Rank::Queen, Suit::Spades),
|
||||
"cards/faces/classic/QS.png",
|
||||
"Queen of Spades must resolve to QS.png, not QD.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_queen_of_diamonds_is_qd_png() {
|
||||
assert_eq!(
|
||||
card_face_asset_path(Rank::Queen, Suit::Diamonds),
|
||||
"cards/faces/classic/QD.png"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_ace_of_clubs_is_ac_png() {
|
||||
assert_eq!(card_face_asset_path(Rank::Ace, Suit::Clubs), "cards/faces/classic/AC.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_ten_of_hearts_is_10h_png() {
|
||||
assert_eq!(card_face_asset_path(Rank::Ten, Suit::Hearts), "cards/faces/classic/10H.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_king_of_spades_is_ks_png() {
|
||||
assert_eq!(card_face_asset_path(Rank::King, Suit::Spades), "cards/faces/classic/KS.png");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_all_52_keys_are_unique() {
|
||||
use std::collections::HashSet;
|
||||
let suits = [Suit::Clubs, Suit::Diamonds, Suit::Hearts, Suit::Spades];
|
||||
let ranks = [
|
||||
Rank::Ace, Rank::Two, Rank::Three, Rank::Four, Rank::Five, Rank::Six,
|
||||
Rank::Seven, Rank::Eight, Rank::Nine, Rank::Ten, Rank::Jack, Rank::Queen, Rank::King,
|
||||
];
|
||||
let paths: HashSet<String> = suits
|
||||
.iter()
|
||||
.flat_map(|&s| ranks.iter().map(move |&r| card_face_asset_path(r, s)))
|
||||
.collect();
|
||||
assert_eq!(paths.len(), 52, "all 52 card face paths must be distinct");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn card_face_asset_path_suits_produce_correct_suffix() {
|
||||
// Each suit must map to its own letter, not a neighbour's.
|
||||
assert!(card_face_asset_path(Rank::Ace, Suit::Clubs).ends_with("AC.png"));
|
||||
assert!(card_face_asset_path(Rank::Ace, Suit::Diamonds).ends_with("AD.png"));
|
||||
assert!(card_face_asset_path(Rank::Ace, Suit::Hearts).ends_with("AH.png"));
|
||||
assert!(card_face_asset_path(Rank::Ace, Suit::Spades).ends_with("AS.png"));
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bug #3 — Suit → color mapping for the Android corner overlay
|
||||
//
|
||||
// Black suits (♠♣) must use BLACK_SUIT_COLOUR (near-white) so they
|
||||
// contrast against the dark card face. They must NOT share the red or
|
||||
// lime colours assigned to red suits.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn text_colour_black_suits_are_near_white_not_red() {
|
||||
for suit in [Suit::Clubs, Suit::Spades] {
|
||||
let card = Card { id: 0, suit, rank: Rank::Ace, face_up: true };
|
||||
let colour = text_colour(&card, false, false);
|
||||
assert_eq!(
|
||||
colour, BLACK_SUIT_COLOUR,
|
||||
"{suit:?} must map to BLACK_SUIT_COLOUR (near-white)"
|
||||
);
|
||||
assert_ne!(
|
||||
colour, RED_SUIT_COLOUR,
|
||||
"{suit:?} must not use the red suit colour"
|
||||
);
|
||||
// Confirm it's visually light (all channels > 0.85).
|
||||
let srgba = colour.to_srgba();
|
||||
assert!(
|
||||
srgba.red > 0.85 && srgba.green > 0.85 && srgba.blue > 0.85,
|
||||
"{suit:?} colour must be near-white for dark card background contrast, got {srgba:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Bug #4 — Waste pile z-ordering
|
||||
//
|
||||
// Every rendered waste card must have a strictly greater z than the one
|
||||
// below it so Bevy's CPU-side sprite sort renders them back-to-front.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn waste_pile_cards_have_strictly_increasing_z() {
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
for _ in 0..5 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.map(|(_, _, z)| *z)
|
||||
.collect();
|
||||
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
waste_zs.dedup();
|
||||
|
||||
assert!(
|
||||
waste_zs.len() >= 2,
|
||||
"expected multiple rendered waste cards, got {}",
|
||||
waste_zs.len()
|
||||
);
|
||||
// All z values must be strictly ordered (no duplicates).
|
||||
for w in waste_zs.windows(2) {
|
||||
assert!(
|
||||
w[1] > w[0],
|
||||
"waste z values must be strictly increasing, got {} ≤ {}",
|
||||
w[1],
|
||||
w[0]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Regression: on tight layouts (e.g. Android H_GAP_DIVISOR=32) the
|
||||
/// Draw-Three waste fan must be proportional to column spacing so that no
|
||||
/// fanned card ever bleeds left into the stock column.
|
||||
///
|
||||
/// The invariant holds structurally (x_offset ≥ 0), but this test pins
|
||||
/// the formula so a future change that accidentally introduces negative
|
||||
/// offsets or flips the fan direction is caught immediately.
|
||||
#[test]
|
||||
fn waste_cards_do_not_overlap_stock_column_on_portrait() {
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
let mut g = GameState::new(42, DrawMode::DrawThree);
|
||||
for _ in 0..5 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
|
||||
// Android-portrait window. In host tests H_GAP_DIVISOR uses the
|
||||
// desktop value (4), but the no-overlap invariant must hold on any
|
||||
// screen size and gap ratio.
|
||||
let window = Vec2::new(900.0, 2000.0);
|
||||
let layout = crate::layout::compute_layout(window, 32.0, 110.0, true);
|
||||
|
||||
let stock_x = layout.pile_positions[&PileType::Stock].x;
|
||||
let stock_right_edge = stock_x + layout.card_size.x / 2.0;
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
let positions = card_positions(&g, &layout);
|
||||
for (card, pos, _) in positions.iter().filter(|(c, _, _)| waste_ids.contains(&c.id)) {
|
||||
let left_edge = pos.x - layout.card_size.x / 2.0;
|
||||
assert!(
|
||||
left_edge >= stock_right_edge - 1e-3,
|
||||
"waste card {} left edge {:.2} overlaps stock right edge {:.2} on portrait window",
|
||||
card.id,
|
||||
left_edge,
|
||||
stock_right_edge,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waste_pile_draw_one_cards_have_distinct_z() {
|
||||
use solitaire_core::game_state::DrawMode;
|
||||
let mut g = GameState::new(42, DrawMode::DrawOne);
|
||||
for _ in 0..3 {
|
||||
let _ = g.draw();
|
||||
}
|
||||
let layout = crate::layout::compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
let positions = card_positions(&g, &layout);
|
||||
|
||||
let waste_ids: std::collections::HashSet<u32> = g.piles[&PileType::Waste]
|
||||
.cards
|
||||
.iter()
|
||||
.map(|c| c.id)
|
||||
.collect();
|
||||
|
||||
let mut waste_zs: Vec<f32> = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.map(|(_, _, z)| *z)
|
||||
.collect();
|
||||
waste_zs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
||||
waste_zs.dedup();
|
||||
|
||||
assert!(
|
||||
waste_zs.len() >= 2,
|
||||
"Draw-One must render at least 2 waste cards (visible + buffer)"
|
||||
);
|
||||
// Deduplicated length must equal pre-dedup length → all z distinct.
|
||||
let raw_count = positions
|
||||
.iter()
|
||||
.filter(|(c, _, _)| waste_ids.contains(&c.id))
|
||||
.count();
|
||||
assert_eq!(
|
||||
waste_zs.len(),
|
||||
raw_count,
|
||||
"all rendered waste card z values must be distinct"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,16 +479,13 @@ impl Plugin for HudPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
/// Spawns the translucent HUD band that anchors the action buttons
|
||||
/// and primary readouts visually. Sits behind every other HUD element
|
||||
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
|
||||
/// without intercepting clicks from the buttons it sits under.
|
||||
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
||||
/// the screen so the card layout (computed by `layout::compute_layout` using
|
||||
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
||||
///
|
||||
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
|
||||
/// same constant the card layout reserves at the top), so the band's
|
||||
/// bottom edge lines up exactly with the top edge of the highest
|
||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
||||
/// alpha, so the green felt reads through subtly.
|
||||
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
||||
/// A slim grey background is handled by each content section individually
|
||||
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
||||
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
||||
const BASE_TOP: f32 = 0.0;
|
||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
||||
@@ -501,10 +498,6 @@ fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
||||
height: Val::Px(HUD_BAND_HEIGHT),
|
||||
..default()
|
||||
},
|
||||
BackgroundColor(BG_HUD_BAND),
|
||||
// Sit one z-rung below the HUD content so the buttons and text
|
||||
// paint on top, but above the card sprites (which are 2D-world
|
||||
// entities and rendered behind UI regardless).
|
||||
ZIndex(Z_HUD - 1),
|
||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||
HudBand,
|
||||
|
||||
@@ -1785,9 +1785,8 @@ mod tests {
|
||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||
// Tableau 6 has 7 cards.
|
||||
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
||||
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
|
||||
// size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5.
|
||||
let expected = layout.card_size.y * 2.5;
|
||||
// Expected: card_height + 6 fan steps.
|
||||
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||
assert!(
|
||||
(size.y - expected).abs() < 1e-3,
|
||||
"expected {expected}, got {}",
|
||||
|
||||
@@ -75,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.
|
||||
@@ -84,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
|
||||
@@ -605,6 +605,74 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Suspend → resume layout-consistency invariant.
|
||||
///
|
||||
/// If the resume handler resets `SafeAreaInsets` to zero and then the JNI
|
||||
/// poller re-resolves the same values, `compute_layout` must produce an
|
||||
/// identical result to the fresh-launch layout. This test also verifies
|
||||
/// that a layout computed with `safe_area_top = 0` (the brief window while
|
||||
/// insets haven't re-resolved after resume) differs visibly from the
|
||||
/// correct layout, confirming that the bug would manifest without the fix.
|
||||
#[test]
|
||||
fn suspend_resume_layout_matches_fresh_launch() {
|
||||
let window = Vec2::new(900.0, 2000.0);
|
||||
let safe_top = 27.0_f32;
|
||||
let safe_bottom = 110.0_f32;
|
||||
|
||||
// Fresh-launch layout — insets known from startup.
|
||||
let fresh = compute_layout(window, safe_top, safe_bottom, true);
|
||||
|
||||
// Layout computed during the brief post-resume window before insets
|
||||
// re-resolve (safe_area_top temporarily 0).
|
||||
let wrong = compute_layout(window, 0.0, safe_bottom, true);
|
||||
|
||||
// Verify the "wrong" layout actually differs — the bug would push the
|
||||
// top card row upward by exactly safe_top pixels.
|
||||
let fresh_stock_y = fresh.pile_positions[&PileType::Stock].y;
|
||||
let wrong_stock_y = wrong.pile_positions[&PileType::Stock].y;
|
||||
// In Bevy's +y-is-up system, adding safe_area_top pushes the stock
|
||||
// downward (−y direction). So wrong_stock_y > fresh_stock_y by safe_top.
|
||||
assert!(
|
||||
(wrong_stock_y - fresh_stock_y - safe_top).abs() < 1e-3,
|
||||
"wrong layout must displace stock upward by safe_top ({safe_top}): \
|
||||
fresh={fresh_stock_y:.2} wrong={wrong_stock_y:.2} delta={:.2}",
|
||||
wrong_stock_y - fresh_stock_y,
|
||||
);
|
||||
|
||||
// After the poller re-resolves correct insets the layout must be
|
||||
// identical to the fresh-launch layout.
|
||||
let corrected = compute_layout(window, safe_top, safe_bottom, true);
|
||||
assert_eq!(
|
||||
corrected.card_size, fresh.card_size,
|
||||
"card size must be preserved after resume",
|
||||
);
|
||||
assert!(
|
||||
(corrected.pile_positions[&PileType::Stock].y - fresh_stock_y).abs() < 1e-3,
|
||||
"stock y must match fresh launch after resume: \
|
||||
corrected={:.2} fresh={fresh_stock_y:.2}",
|
||||
corrected.pile_positions[&PileType::Stock].y,
|
||||
);
|
||||
assert!(
|
||||
(corrected.pile_positions[&PileType::Stock].x
|
||||
- fresh.pile_positions[&PileType::Stock].x)
|
||||
.abs()
|
||||
< 1e-3,
|
||||
"stock x must be unchanged after resume",
|
||||
);
|
||||
// The HUD band top clearance (distance from window top to card top)
|
||||
// must match as well — this is the quantity directly visible in Bug 2.
|
||||
let card_top = |layout: &super::Layout| {
|
||||
layout.pile_positions[&PileType::Stock].y + layout.card_size.y / 2.0
|
||||
};
|
||||
assert!(
|
||||
(card_top(&corrected) - card_top(&fresh)).abs() < 1e-3,
|
||||
"top-of-card must match fresh launch after resume: \
|
||||
corrected={:.2} fresh={:.2}",
|
||||
card_top(&corrected),
|
||||
card_top(&fresh),
|
||||
);
|
||||
}
|
||||
|
||||
/// safe_area_bottom must not affect horizontal positions.
|
||||
#[test]
|
||||
fn safe_area_bottom_does_not_affect_horizontal_layout() {
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
//! changes flow through automatically.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::{AppLifecycle, WindowResized};
|
||||
|
||||
use crate::ui_modal::ModalScrim;
|
||||
|
||||
@@ -65,14 +66,25 @@ pub struct SafeAreaInsetsPlugin;
|
||||
|
||||
impl Plugin for SafeAreaInsetsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<SafeAreaInsets>()
|
||||
// Both message types may already be registered by GamePlugin / TablePlugin;
|
||||
// add_message is idempotent.
|
||||
app.add_message::<AppLifecycle>()
|
||||
.add_message::<WindowResized>()
|
||||
.init_resource::<SafeAreaInsets>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(apply_safe_area_anchors, apply_safe_area_bottom_anchors, apply_safe_area_to_modal_scrims),
|
||||
(
|
||||
apply_safe_area_anchors,
|
||||
apply_safe_area_bottom_anchors,
|
||||
apply_safe_area_to_modal_scrims,
|
||||
on_app_resumed,
|
||||
),
|
||||
);
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
app.add_systems(Update, android::refresh_insets);
|
||||
app.init_resource::<android::SafeAreaPollTries>()
|
||||
.add_systems(Update, android::refresh_insets)
|
||||
.add_systems(Update, android::rearm_on_resumed);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,33 +154,73 @@ fn apply_safe_area_to_modal_scrims(
|
||||
}
|
||||
}
|
||||
|
||||
/// Emits a synthetic `WindowResized` on `AppLifecycle::WillResume` so that
|
||||
/// `on_window_resized` (in `table_plugin`) recomputes the board layout with
|
||||
/// whatever `SafeAreaInsets` are current at that moment.
|
||||
///
|
||||
/// On Android the `android::rearm_on_resumed` system runs in the same frame
|
||||
/// and resets both `SafeAreaPollTries` and `SafeAreaInsets` to zero, causing
|
||||
/// `refresh_insets` to re-poll JNI over the next few frames. When it resolves
|
||||
/// the correct values, `on_safe_area_changed` in `table_plugin` emits a second
|
||||
/// synthetic `WindowResized` and the layout converges to the right position.
|
||||
///
|
||||
/// On non-Android targets this handler still fires — it ensures that a resume
|
||||
/// event always refreshes the layout (e.g., after a minimise/restore on
|
||||
/// desktop) even though insets are always zero.
|
||||
fn on_app_resumed(
|
||||
mut lifecycle: MessageReader<AppLifecycle>,
|
||||
windows: Query<(Entity, &Window)>,
|
||||
mut resize_events: MessageWriter<WindowResized>,
|
||||
) {
|
||||
for event in lifecycle.read() {
|
||||
if !matches!(event, AppLifecycle::WillResume) {
|
||||
continue;
|
||||
}
|
||||
let Some((entity, window)) = windows.iter().next() else {
|
||||
return;
|
||||
};
|
||||
resize_events.write(WindowResized {
|
||||
window: entity,
|
||||
width: window.resolution.width(),
|
||||
height: window.resolution.height(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android {
|
||||
use super::SafeAreaInsets;
|
||||
use super::{AppLifecycle, SafeAreaInsets};
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Tracks how many frames `refresh_insets` has polled. Stored as a
|
||||
/// `Resource` (not `Local`) so that `rearm_on_resumed` can reset it to 0
|
||||
/// when `AppLifecycle::WillResume` fires, causing the poller to re-query JNI
|
||||
/// after a background/foreground cycle.
|
||||
#[derive(Resource, Default)]
|
||||
pub(super) struct SafeAreaPollTries(pub u32);
|
||||
|
||||
/// Polls Android for safe-area insets until we get a non-zero
|
||||
/// reading, then stops. `getRootWindowInsets()` returns `null` (or
|
||||
/// all-zero `Insets`) until the decor view has been laid out, which
|
||||
/// is typically frame 1–3 of a fresh launch.
|
||||
pub(super) fn refresh_insets(
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
mut tries: Local<u32>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
) {
|
||||
// Cap retries so we don't burn CPU forever on edge-to-edge
|
||||
// devices that genuinely report zero insets.
|
||||
const MAX_TRIES: u32 = 120; // ~2 seconds @ 60 fps
|
||||
|
||||
if *tries >= MAX_TRIES || insets.is_populated() {
|
||||
if poll.0 >= MAX_TRIES || insets.is_populated() {
|
||||
return;
|
||||
}
|
||||
*tries += 1;
|
||||
poll.0 += 1;
|
||||
|
||||
match query_insets() {
|
||||
Ok(v) if v.is_populated() => {
|
||||
info!(
|
||||
"safe_area: insets resolved top={} bottom={} left={} right={} (after {} frames)",
|
||||
v.top, v.bottom, v.left, v.right, *tries
|
||||
v.top, v.bottom, v.left, v.right, poll.0
|
||||
);
|
||||
*insets = v;
|
||||
}
|
||||
@@ -177,13 +229,35 @@ mod android {
|
||||
}
|
||||
Err(e) => {
|
||||
// Don't spam — log once and let polling continue silently.
|
||||
if *tries == 1 {
|
||||
if poll.0 == 1 {
|
||||
warn!("safe_area: JNI query failed (will retry): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets the inset poller and clears cached insets on
|
||||
/// `AppLifecycle::WillResume` so that `refresh_insets` re-queries JNI in the
|
||||
/// frames immediately after the app returns to the foreground.
|
||||
///
|
||||
/// Clearing `SafeAreaInsets` to the default (all-zero) fires
|
||||
/// `on_safe_area_changed` in `table_plugin`, which emits a synthetic
|
||||
/// `WindowResized`. `on_window_resized` then recomputes the layout;
|
||||
/// once `refresh_insets` resolves the real values a second synthetic
|
||||
/// `WindowResized` fires and the layout converges to the correct position.
|
||||
pub(super) fn rearm_on_resumed(
|
||||
mut lifecycle: MessageReader<AppLifecycle>,
|
||||
mut poll: ResMut<SafeAreaPollTries>,
|
||||
mut insets: ResMut<SafeAreaInsets>,
|
||||
) {
|
||||
for event in lifecycle.read() {
|
||||
if matches!(event, AppLifecycle::WillResume) {
|
||||
poll.0 = 0;
|
||||
*insets = SafeAreaInsets::default();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn query_insets() -> Result<SafeAreaInsets, String> {
|
||||
use bevy::android::ANDROID_APP;
|
||||
use jni::{objects::JObject, JavaVM};
|
||||
|
||||
@@ -129,7 +129,7 @@ fn load_initial_theme(
|
||||
let id = settings
|
||||
.as_deref()
|
||||
.map(|s| s.0.selected_theme_id.as_str())
|
||||
.unwrap_or("dark");
|
||||
.unwrap_or("classic");
|
||||
let url = bundled_theme_url(id)
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("themes://{id}/theme.ron"));
|
||||
|
||||
@@ -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,6 +30,7 @@
|
||||
<section id="board"></section>
|
||||
|
||||
<section id="controls">
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -319,6 +322,7 @@ function stepBack() {
|
||||
}
|
||||
render(player.state());
|
||||
btnPrev.disabled = player.step_idx() === 0;
|
||||
btnRestart.disabled = player.step_idx() === 0;
|
||||
btnStep.disabled = false;
|
||||
btnPlay.disabled = false;
|
||||
}
|
||||
@@ -327,4 +331,11 @@ btnPrev.addEventListener("click", () => {
|
||||
if (player) stepBack();
|
||||
});
|
||||
|
||||
btnRestart.addEventListener("click", () => {
|
||||
if (!replayJson) return;
|
||||
cardEls.forEach((el) => el.remove());
|
||||
cardEls.clear();
|
||||
resetPlayer();
|
||||
});
|
||||
|
||||
bootstrap();
|
||||
|
||||
Reference in New Issue
Block a user