Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,43 @@ project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ const hudMoves = document.getElementById("hud-moves");
|
||||
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 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");
|
||||
@@ -243,7 +244,8 @@ function render(s) {
|
||||
hudScore.textContent = `Score: ${s.score}`;
|
||||
hudMoves.textContent = `Moves: ${s.move_count}`;
|
||||
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 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