Compare commits
19 Commits
a9285ccb41
...
v0.32.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 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*'
|
- 'v*'
|
||||||
|
|
||||||
env:
|
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
|
APK_OUT: target/release/apk/ferrous-solitaire.apk
|
||||||
GITEA_URL: https://git.aleshym.co
|
GITEA_URL: https://git.aleshym.co
|
||||||
REPO: funman300/Ferrous-Solitaire
|
REPO: funman300/Ferrous-Solitaire
|
||||||
@@ -17,75 +13,56 @@ env:
|
|||||||
jobs:
|
jobs:
|
||||||
build-apk:
|
build-apk:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: git.aleshym.co/funman300/android-builder:latest
|
||||||
|
credentials:
|
||||||
|
username: ${{ gitea.actor }}
|
||||||
|
password: ${{ secrets.CI_TOKEN }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Java 17
|
- name: Cache Cargo registry
|
||||||
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
|
|
||||||
uses: actions/cache@v4
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.cargo/registry/index
|
/usr/local/cargo/registry/index
|
||||||
~/.cargo/registry/cache
|
/usr/local/cargo/registry/cache
|
||||||
~/.cargo/git/db
|
/usr/local/cargo/git/db
|
||||||
~/.cargo/bin/cargo-ndk
|
key: cargo-registry-android-${{ hashFiles('**/Cargo.lock') }}
|
||||||
target/aarch64-linux-android
|
restore-keys: cargo-registry-android-
|
||||||
key: cargo-android-${{ hashFiles('**/Cargo.lock') }}
|
|
||||||
restore-keys: cargo-android-
|
|
||||||
|
|
||||||
- name: Install cargo-ndk
|
- name: Cache sccache
|
||||||
run: cargo-ndk --version 2>/dev/null || cargo install cargo-ndk --version 4.1.2 --locked
|
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
|
- name: Decode release keystore
|
||||||
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
run: echo "${{ secrets.RELEASE_KEYSTORE_B64 }}" | base64 -d > release.jks
|
||||||
|
|
||||||
- name: Build release APK
|
- name: Build release APK
|
||||||
env:
|
env:
|
||||||
ANDROID_NDK_HOME: /opt/android-sdk/ndk/${{ env.NDK_VERSION }}
|
|
||||||
PROFILE: release
|
PROFILE: release
|
||||||
ABIS: arm64-v8a
|
ABIS: arm64-v8a
|
||||||
KEYSTORE: ./release.jks
|
KEYSTORE: ./release.jks
|
||||||
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
KEYSTORE_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
||||||
KEY_ALIAS: release
|
KEY_ALIAS: release
|
||||||
KEY_PASS: ${{ secrets.RELEASE_KEYSTORE_PASS }}
|
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
|
run: bash scripts/build_android_apk.sh
|
||||||
|
|
||||||
- name: Get tag name
|
- name: sccache stats
|
||||||
id: tag
|
if: always()
|
||||||
run: echo "name=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT"
|
run: sccache --show-stats
|
||||||
|
|
||||||
- name: Create or get Gitea release
|
- name: Create or get Gitea release
|
||||||
id: release
|
id: release
|
||||||
@@ -94,7 +71,6 @@ jobs:
|
|||||||
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
|
BASE="${{ env.GITEA_URL }}/api/v1/repos/${{ env.REPO }}"
|
||||||
AUTH="Authorization: token ${{ secrets.CI_TOKEN }}"
|
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" \
|
ID=$(curl -sf -H "$AUTH" "$BASE/releases/tags/$TAG" \
|
||||||
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
|
| python3 -c "import sys,json; print(json.load(sys.stdin).get('id',''))" \
|
||||||
2>/dev/null || true)
|
2>/dev/null || true)
|
||||||
@@ -116,7 +92,18 @@ jobs:
|
|||||||
|
|
||||||
- name: Upload APK to release
|
- name: Upload APK to release
|
||||||
run: |
|
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 \
|
curl -sf -X POST \
|
||||||
-H "Authorization: token ${{ secrets.CI_TOKEN }}" \
|
-H "$AUTH" \
|
||||||
-F "attachment=@${{ env.APK_OUT }};type=application/vnd.android.package-archive" \
|
-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
|
- name: Install kustomize
|
||||||
run: |
|
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
|
sudo mv kustomize /usr/local/bin/kustomize
|
||||||
|
|
||||||
- name: Pin image tag in deploy manifests
|
- name: Pin image tag in deploy manifests
|
||||||
|
|||||||
@@ -6,6 +6,61 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [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
|
## [0.28.0] — 2026-05-14
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
@@ -20,4 +20,4 @@ resources:
|
|||||||
images:
|
images:
|
||||||
- name: solitaire-server
|
- name: solitaire-server
|
||||||
newName: git.aleshym.co/funman300/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"
|
"$BT/aapt2" compile --dir "$RES_DIR" -o "$STAGING/compiled-res"
|
||||||
fi
|
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_ARGS=(
|
||||||
link
|
link
|
||||||
-o "$STAGING/app-unsigned.apk"
|
-o "$STAGING/app-unsigned.apk"
|
||||||
-I "$PLATFORM_JAR"
|
-I "$PLATFORM_JAR"
|
||||||
--manifest "$MANIFEST"
|
--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" )
|
[ -d "$ASSETS_DIR" ] && LINK_ARGS+=( -A "$ASSETS_DIR" )
|
||||||
# Add compiled resources if any
|
# Add compiled resources if any
|
||||||
shopt -s nullglob
|
shopt -s nullglob
|
||||||
|
|||||||
@@ -13,9 +13,7 @@
|
|||||||
shared object name without the `lib` prefix or `.so` suffix.
|
shared object name without the `lib` prefix or `.so` suffix.
|
||||||
-->
|
-->
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="com.ferrousapp.solitaire"
|
package="com.ferrousapp.solitaire">
|
||||||
android:versionCode="1"
|
|
||||||
android:versionName="1.0">
|
|
||||||
|
|
||||||
<uses-sdk
|
<uses-sdk
|
||||||
android:minSdkVersion="26"
|
android:minSdkVersion="26"
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ fn default_music_volume() -> f32 {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn default_theme_id() -> String {
|
fn default_theme_id() -> String {
|
||||||
"dark".to_string()
|
"classic".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
/// Default tooltip-hover dwell delay in seconds. Mirrors
|
||||||
@@ -402,11 +402,10 @@ impl Settings {
|
|||||||
/// their respective ranges after deserialization or hand-editing of
|
/// their respective ranges after deserialization or hand-editing of
|
||||||
/// `settings.json`.
|
/// `settings.json`.
|
||||||
pub fn sanitized(self) -> Self {
|
pub fn sanitized(self) -> Self {
|
||||||
// Migrate stale theme IDs: "default" was removed when the theme was
|
// Migrate stale theme IDs: "default" was the original name before it
|
||||||
// renamed to "dark"; "classic" was briefly the default before "dark"
|
// was renamed to "dark".
|
||||||
// was restored as the shipped default.
|
|
||||||
let selected_theme_id = match self.selected_theme_id.as_str() {
|
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.selected_theme_id,
|
||||||
};
|
};
|
||||||
Self {
|
Self {
|
||||||
|
|||||||
@@ -484,13 +484,13 @@ fn load_card_images(asset_server: Option<Res<AssetServer>>, mut commands: Comman
|
|||||||
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
|
let faces: [[Handle<Image>; 13]; 4] = std::array::from_fn(|suit| {
|
||||||
std::array::from_fn(|rank| {
|
std::array::from_fn(|rank| {
|
||||||
asset_server.load(format!(
|
asset_server.load(format!(
|
||||||
"cards/faces/{}{}.png",
|
"cards/faces/classic/{}{}.png",
|
||||||
RANK_STRS[rank], SUIT_CHARS[suit]
|
RANK_STRS[rank], SUIT_CHARS[suit]
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
let backs = std::array::from_fn(|i| {
|
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 {
|
commands.insert_resource(CardImageSet {
|
||||||
faces,
|
faces,
|
||||||
@@ -1614,10 +1614,11 @@ fn update_stock_empty_indicator(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Inset (in pixels) from the top-right corner of the stock pile sprite to
|
/// 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
|
/// the centre of the count badge. Must satisfy `|x| >= STOCK_BADGE_SIZE.x / 2`
|
||||||
/// drifting half-off the card while still reading as "attached" to the
|
/// so the badge right edge stays inside the stock pile and never overlaps the
|
||||||
/// corner.
|
/// adjacent waste pile — critical on Android where `H_GAP_DIVISOR = 32` gives
|
||||||
const STOCK_BADGE_INSET: Vec2 = Vec2::new(-12.0, -8.0);
|
/// 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
|
/// 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.
|
/// a 2-digit count (max "24") fits comfortably with `TYPE_BODY` (14 pt) text.
|
||||||
|
|||||||
@@ -479,16 +479,13 @@ impl Plugin for HudPlugin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns the translucent HUD band that anchors the action buttons
|
/// Spawns the invisible HUD band that reserves vertical space at the top of
|
||||||
/// and primary readouts visually. Sits behind every other HUD element
|
/// the screen so the card layout (computed by `layout::compute_layout` using
|
||||||
/// (one z-rung below `Z_HUD`) so it reads as the band's "container"
|
/// `HUD_BAND_HEIGHT`) aligns correctly below the score readouts.
|
||||||
/// without intercepting clicks from the buttons it sits under.
|
|
||||||
///
|
///
|
||||||
/// Width is full-window, height matches `layout::HUD_BAND_HEIGHT` (the
|
/// The entity carries no `BackgroundColor` — the green felt shows through.
|
||||||
/// same constant the card layout reserves at the top), so the band's
|
/// A slim grey background is handled by each content section individually
|
||||||
/// bottom edge lines up exactly with the top edge of the highest
|
/// (the bottom action bar has its own `BG_HUD_BAND` background).
|
||||||
/// playable card. The fill is `BG_HUD_BAND` — midnight purple at 0.70
|
|
||||||
/// alpha, so the green felt reads through subtly.
|
|
||||||
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
fn spawn_hud_band(insets: Option<Res<SafeAreaInsets>>, mut commands: Commands) {
|
||||||
const BASE_TOP: f32 = 0.0;
|
const BASE_TOP: f32 = 0.0;
|
||||||
let top_inset = insets.as_deref().copied().unwrap_or_default().top;
|
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),
|
height: Val::Px(HUD_BAND_HEIGHT),
|
||||||
..default()
|
..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),
|
ZIndex(Z_HUD - 1),
|
||||||
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
SafeAreaAnchoredTop { base_top: BASE_TOP },
|
||||||
HudBand,
|
HudBand,
|
||||||
|
|||||||
@@ -1785,9 +1785,8 @@ mod tests {
|
|||||||
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
let layout = compute_layout(Vec2::new(1280.0, 800.0), 0.0, 0.0, true);
|
||||||
// Tableau 6 has 7 cards.
|
// Tableau 6 has 7 cards.
|
||||||
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
let (_, size) = pile_drop_rect(&PileType::Tableau(6), &layout, &game);
|
||||||
// Expected: card_height + 6 * fan. fan = 0.25 * card_height, so
|
// Expected: card_height + 6 fan steps.
|
||||||
// size.y = card_height * (1 + 6 * 0.25) = card_height * 2.5.
|
let expected = layout.card_size.y * (1.0 + 6.0 * layout.tableau_fan_frac);
|
||||||
let expected = layout.card_size.y * 2.5;
|
|
||||||
assert!(
|
assert!(
|
||||||
(size.y - expected).abs() < 1e-3,
|
(size.y - expected).abs() < 1e-3,
|
||||||
"expected {expected}, got {}",
|
"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
|
/// column must fit at this fraction). On desktop (height-limited) windows the
|
||||||
/// adaptive computation returns this value exactly; on portrait phones it
|
/// adaptive computation returns this value exactly; on portrait phones it
|
||||||
/// expands to fill available vertical space.
|
/// 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
|
/// Minimum fraction for face-down tableau cards. Scales proportionally with
|
||||||
/// the adaptive face-up fraction so hit-testing and rendering stay in sync.
|
/// 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
|
/// 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
|
/// thin sliver. The ratio to TABLEAU_FAN_FRAC (0.80) is preserved by
|
||||||
/// the adaptive scaling in `compute_layout`.
|
/// 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
|
/// 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
|
/// after every face-down card has flipped on column 7. Layout sizing must keep
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ header {
|
|||||||
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
|
.hud-center { display: flex; gap: 20px; font-size: 14px; font-weight: 600; }
|
||||||
.hud-right { display: flex; align-items: center; gap: 10px; }
|
.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; }
|
.logo { font-size: 16px; font-weight: 700; }
|
||||||
.muted { color: var(--text-muted); font-size: 12px; }
|
.muted { color: var(--text-muted); font-size: 12px; }
|
||||||
.home-link {
|
.home-link {
|
||||||
@@ -98,6 +117,16 @@ button:disabled { opacity: 0.4; cursor: default; }
|
|||||||
|
|
||||||
/* ── Board ───────────────────────────────────────────────────────────── */
|
/* ── Board ───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.board-undo {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 50;
|
||||||
|
font-size: 15px;
|
||||||
|
padding: 8px 20px;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -108,6 +137,7 @@ main {
|
|||||||
|
|
||||||
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
|
/* Full-bleed felt surface — flex:1 reliably fills main's remaining height. */
|
||||||
#board {
|
#board {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background: var(--felt);
|
background: var(--felt);
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -40,12 +40,19 @@
|
|||||||
<input type="checkbox" id="chk-draw3"> Draw 3
|
<input type="checkbox" id="chk-draw3"> Draw 3
|
||||||
</label>
|
</label>
|
||||||
<button id="btn-theme" title="Switch card theme">Dark</button>
|
<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>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section id="board">
|
<section id="board">
|
||||||
<div id="card-area"></div>
|
<div id="card-area"></div>
|
||||||
|
<button id="btn-board-undo" class="board-undo" title="Undo (Z)" disabled>↩ Undo</button>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -103,7 +103,8 @@ const hudMoves = document.getElementById("hud-moves");
|
|||||||
const hudTimer = document.getElementById("hud-timer");
|
const hudTimer = document.getElementById("hud-timer");
|
||||||
const hudStock = document.getElementById("hud-stock");
|
const hudStock = document.getElementById("hud-stock");
|
||||||
const hudSeed = document.getElementById("hud-seed");
|
const hudSeed = document.getElementById("hud-seed");
|
||||||
const btnUndo = document.getElementById("btn-undo");
|
const btnUndo = document.getElementById("btn-undo");
|
||||||
|
const btnBoardUndo = document.getElementById("btn-board-undo");
|
||||||
const btnNew = document.getElementById("btn-new");
|
const btnNew = document.getElementById("btn-new");
|
||||||
const chkDraw3 = document.getElementById("chk-draw3");
|
const chkDraw3 = document.getElementById("chk-draw3");
|
||||||
const btnTheme = document.getElementById("btn-theme");
|
const btnTheme = document.getElementById("btn-theme");
|
||||||
@@ -243,7 +244,8 @@ function render(s) {
|
|||||||
hudScore.textContent = `Score: ${s.score}`;
|
hudScore.textContent = `Score: ${s.score}`;
|
||||||
hudMoves.textContent = `Moves: ${s.move_count}`;
|
hudMoves.textContent = `Moves: ${s.move_count}`;
|
||||||
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
|
if (hudStock) hudStock.textContent = `Stock: ${s.stock.length}`;
|
||||||
btnUndo.disabled = s.undo_stack_len === 0;
|
btnUndo.disabled = s.undo_stack_len === 0;
|
||||||
|
btnBoardUndo.disabled = s.undo_stack_len === 0;
|
||||||
|
|
||||||
const visible = new Map();
|
const visible = new Map();
|
||||||
const addPile = (name, cards) =>
|
const addPile = (name, cards) =>
|
||||||
@@ -385,10 +387,9 @@ function flashIllegal(cardIds) {
|
|||||||
|
|
||||||
// ── Input ─────────────────────────────────────────────────────────────────────
|
// ── Input ─────────────────────────────────────────────────────────────────────
|
||||||
function attachHandlers() {
|
function attachHandlers() {
|
||||||
btnUndo.addEventListener("click", () => {
|
const doUndo = () => { const r = game.undo(); if (r.ok) render(r.snapshot); };
|
||||||
const r = game.undo();
|
btnUndo.addEventListener("click", doUndo);
|
||||||
if (r.ok) render(r.snapshot);
|
btnBoardUndo.addEventListener("click", doUndo);
|
||||||
});
|
|
||||||
btnNew.addEventListener("click", () => startGame(randomSeed()));
|
btnNew.addEventListener("click", () => startGame(randomSeed()));
|
||||||
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
|
btnWinNew.addEventListener("click", () => startGame(randomSeed()));
|
||||||
chkDraw3.addEventListener("change", () => {
|
chkDraw3.addEventListener("change", () => {
|
||||||
@@ -404,7 +405,7 @@ function attachHandlers() {
|
|||||||
|
|
||||||
document.addEventListener("keydown", (e) => {
|
document.addEventListener("keydown", (e) => {
|
||||||
if (e.target.tagName === "INPUT") return;
|
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());
|
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]);
|
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 ─────────────────────────────────────────────────────────────────────
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
||||||
bootstrap().catch(console.error);
|
bootstrap().catch(console.error);
|
||||||
|
loadAvatar();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<section id="board"></section>
|
<section id="board"></section>
|
||||||
|
|
||||||
<section id="controls">
|
<section id="controls">
|
||||||
|
<button id="btn-restart" disabled>⏮ Restart</button>
|
||||||
<button id="btn-prev" disabled>◀ Back</button>
|
<button id="btn-prev" disabled>◀ Back</button>
|
||||||
<button id="btn-play">▶ Play</button>
|
<button id="btn-play">▶ Play</button>
|
||||||
<button id="btn-step">⏭ Step</button>
|
<button id="btn-step">⏭ Step</button>
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const resultEl = document.getElementById("result");
|
|||||||
const btnPlay = document.getElementById("btn-play");
|
const btnPlay = document.getElementById("btn-play");
|
||||||
const btnStep = document.getElementById("btn-step");
|
const btnStep = document.getElementById("btn-step");
|
||||||
const btnPrev = document.getElementById("btn-prev");
|
const btnPrev = document.getElementById("btn-prev");
|
||||||
|
const btnRestart = document.getElementById("btn-restart");
|
||||||
|
|
||||||
let player = null;
|
let player = null;
|
||||||
let replayJson = null;
|
let replayJson = null;
|
||||||
@@ -122,6 +123,7 @@ function resetPlayer() {
|
|||||||
}
|
}
|
||||||
player = new ReplayPlayer(replayJson);
|
player = new ReplayPlayer(replayJson);
|
||||||
btnPrev.disabled = true;
|
btnPrev.disabled = true;
|
||||||
|
btnRestart.disabled = true;
|
||||||
btnStep.disabled = false;
|
btnStep.disabled = false;
|
||||||
btnPlay.disabled = false;
|
btnPlay.disabled = false;
|
||||||
render(player.state());
|
render(player.state());
|
||||||
@@ -134,6 +136,7 @@ function step() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
btnPrev.disabled = false;
|
btnPrev.disabled = false;
|
||||||
|
btnRestart.disabled = false;
|
||||||
render(snap);
|
render(snap);
|
||||||
return snap;
|
return snap;
|
||||||
}
|
}
|
||||||
@@ -319,6 +322,7 @@ function stepBack() {
|
|||||||
}
|
}
|
||||||
render(player.state());
|
render(player.state());
|
||||||
btnPrev.disabled = player.step_idx() === 0;
|
btnPrev.disabled = player.step_idx() === 0;
|
||||||
|
btnRestart.disabled = player.step_idx() === 0;
|
||||||
btnStep.disabled = false;
|
btnStep.disabled = false;
|
||||||
btnPlay.disabled = false;
|
btnPlay.disabled = false;
|
||||||
}
|
}
|
||||||
@@ -327,4 +331,11 @@ btnPrev.addEventListener("click", () => {
|
|||||||
if (player) stepBack();
|
if (player) stepBack();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
btnRestart.addEventListener("click", () => {
|
||||||
|
if (!replayJson) return;
|
||||||
|
cardEls.forEach((el) => el.remove());
|
||||||
|
cardEls.clear();
|
||||||
|
resetPlayer();
|
||||||
|
});
|
||||||
|
|
||||||
bootstrap();
|
bootstrap();
|
||||||
|
|||||||
Reference in New Issue
Block a user