Compare commits

...

19 Commits

Author SHA1 Message Date
funman300 df22338c8a fix(ui): remove grey HUD band background and constrain stock badge to pile bounds
Android Release / build-apk (push) Successful in 4m30s
Bug 1: StockCountBadge was centred 12 px inward from the stock pile's right
edge but its half-width of 17 px pushed the right edge 5 px past the pile
boundary. On Android (H_GAP_DIVISOR=32, inter-pile gap ~4 px) the badge
corner covered the waste pile's left edge at Z=30, making the waste card
appear clipped. STOCK_BADGE_INSET.x: -12 → -20 keeps the right edge 3 px
inside the stock pile on every device.

Bug 2: The top HUD band Node had an opaque dark-grey BackgroundColor sized to
HUD_BAND_HEIGHT (64/80 px). With only Tier-1 content (~30 px) visible in
typical gameplay the grey block appeared far taller than its content. Removed
BackgroundColor from the band entity; layout reservation in compute_layout is
unchanged and the bottom action bar retains its own background.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:48:52 -07:00
funman300 7f450aab17 fix(android): default to classic theme to fix AMOLED card-back invisibility
Android Release / build-apk (push) Successful in 4m7s
Dark theme back.svg uses #151515 (near-black) as the card back background,
which AMOLED screens render as fully-off pixels, leaving only the tiny
#a54242 red badge visible — user sees solid red squares instead of card backs.

Fix: change fresh-install default theme from "dark" to "classic" (white
background with navy diamond pattern, clearly visible on all display types).
Also remove the stale "classic" -> "dark" sanitize migration, correct wrong
asset paths in load_card_images (classic/ subdirectory was missing), and
update tests that hardcoded the old TABLEAU_FAN_FRAC=0.25 constant.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 13:24:25 -07:00
funman300 d8f67dcad3 fix(ci): collapse multi-line Python to one-liner to fix YAML block scalar indentation error
Android Release / build-apk (push) Successful in 4m3s
2026-05-16 12:34:40 -07:00
funman300 ccb77f76b8 chore(release): promote Unreleased to 0.30.0 2026-05-16 12:31:51 -07:00
funman300 da54faf8e2 feat(engine): tighten tableau card fan offset (0.25→0.18, 0.20→0.14)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:31:18 -07:00
funman300 f3d01b5890 fix(ci): delete existing APK assets before upload to avoid duplicates on re-runs
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:20:10 -07:00
funman300 faefca0445 fix(android): remove hardcoded versionCode/Name from manifest so aapt2 CI injection works
Android Release / build-apk (push) Successful in 3m44s
aapt2 --version-code/--version-name only inject when the attribute is
absent — they silently no-op when the manifest already has a value.
Removed both attributes from AndroidManifest.xml so the CI flags take
effect. Local debug builds fall back to code=1 / name=0.0.0-dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 12:11:22 -07:00
funman300 24d83c9ae3 fix(ci): add Node.js 20 to android-builder for Gitea Actions composite steps
Build Android Builder Image / build (push) Successful in 5m7s
Android Release / build-apk (push) Successful in 5m43s
2026-05-16 11:30:10 -07:00
funman300 9d4234cded fix(ci): add build-essential to android-builder image for cargo-ndk compile
Build Android Builder Image / build (push) Successful in 7m5s
Android Release / build-apk (push) Failing after 3m30s
2026-05-16 10:51:48 -07:00
funman300 e48f652454 feat(ci): pre-built Android builder image + sccache
Build Android Builder Image / build (push) Failing after 3m54s
Replaces the 5 per-run tool-install steps (~2m 30s) with a pre-built
container image (git.aleshym.co/funman300/android-builder) that ships
Ubuntu 22.04 + Java 17 + Android SDK/NDK + Rust stable + aarch64 target
+ cargo-ndk + sccache. android-release.yml now runs inside the container
and adds two cache steps instead: Cargo registry and sccache directory.

sccache (RUSTC_WRAPPER) caches at the translation-unit level so partial
hits survive Cargo.lock changes — far more resilient than caching the
full target/ directory.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:47:05 -07:00
funman300 c24c7f6b61 chore(release): promote Unreleased to 0.29.0
Android Release / build-apk (push) Failing after 2m46s
2026-05-16 10:35:32 -07:00
funman300 686f57252c fix(android): stamp versionCode and versionName from the release tag
AndroidManifest.xml had hardcoded versionCode=1 / versionName=1.0, so
every shipped APK looked identical to Android and Obtainium could never
confirm the installed version matched the latest release tag — causing
a persistent false-update notification loop.

VERSION_NAME is now passed into the build script from the CI tag
(e.g. "v0.28.0" → versionCode=2800, versionName="0.28.0") and
forwarded to aapt2 link via --version-code / --version-name, overriding
the manifest without touching the file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:34:14 -07:00
Gitea CI 059af2ac28 chore(deploy): bump image to 858012d9 [skip ci] 2026-05-16 17:29:27 +00:00
funman300 858012d926 fix(ci): pin kustomize to v5.4.3 to avoid GitHub API rate-limit failures
Build and Deploy / build-and-push (push) Successful in 22s
Replaced the curl|bash install_kustomize.sh approach (which makes an
unauthenticated GitHub API call to resolve the latest version) with a
direct pinned tarball download. Eliminates the tar glob failure that
broke run #226.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 10:29:02 -07:00
funman300 f6be961419 feat(web): show profile picture avatar in game page header
Build and Deploy / build-and-push (push) Failing after 4m17s
Fetches /api/me with the stored fs_token and renders a 32px circular
avatar in hud-right. Shows the profile photo when set, or the first
letter of the username as initials otherwise. Hidden when not signed in.
Clicking the avatar navigates to /account.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:37:59 -07:00
Gitea CI 8a145154db chore(deploy): bump image to e17667d0 [skip ci] 2026-05-16 00:36:52 +00:00
funman300 e17667d034 feat(web): add undo button directly on the game board
Build and Deploy / build-and-push (push) Successful in 4m37s
Places a floating "↩ Undo" button at the bottom-right of the green felt
surface so it is visible without looking in the header. Both the board
button and the header button share the same handler; both track
undo_stack_len and disable when nothing can be undone.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:32:16 -07:00
Gitea CI 005e29d1ab chore(deploy): bump image to a9285ccb [skip ci] 2026-05-16 00:25:22 +00:00
funman300 9d3cc94831 feat(web): add Restart button to replay viewer
Build and Deploy / build-and-push (push) Successful in 4m31s
Splits the old single "⏮ Restart" button into two: "⏮ Restart" (resets
to step 0 with card fade-in from dealt positions) and "◀ Back" (steps
back one move at a time via fast-forward replay). Both are disabled at
step 0 and enabled after any forward step.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 17:24:25 -07:00
18 changed files with 308 additions and 94 deletions
+40 -53
View File
@@ -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"
+41
View File
@@ -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
+1 -1
View File
@@ -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
+55
View File
@@ -6,6 +6,61 @@ project follows [Semantic Versioning](https://semver.org/).
## [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
### Changed
+1 -1
View File
@@ -20,4 +20,4 @@ resources:
images:
- name: solitaire-server
newName: git.aleshym.co/funman300/solitaire-server
newTag: d761a150
newTag: 858012d9
+50
View File
@@ -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
+15
View File
@@ -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
+1 -3
View File
@@ -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"
+4 -5
View File
@@ -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 {
+7 -6
View File
@@ -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| {
std::array::from_fn(|rank| {
asset_server.load(format!(
"cards/faces/{}{}.png",
"cards/faces/classic/{}{}.png",
RANK_STRS[rank], SUIT_CHARS[suit]
))
})
});
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,
@@ -1614,10 +1614,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.
+6 -13
View File
@@ -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,
+2 -3
View File
@@ -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 {}",
+2 -2
View File
@@ -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
+30
View File
@@ -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;
+7
View File
@@ -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>
+32 -5
View File
@@ -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();
+1
View File
@@ -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>
+11
View File
@@ -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();