Compare commits

..

1 Commits

Author SHA1 Message Date
funman300 b2581024f3 feat(engine): surface upstream move-type counters and replay seek
Test / test (pull_request) Successful in 36m35s
Two unused-for-free upstream card_game/klondike features:

- GameState now exposes the granular KlondikeStats counters
  (move_to_foundation_count, move_to_tableau_count,
  move_from_foundation_count, flip_up_count) and the win modal shows a
  quiet per-move-type recap line built from them (e.g. "21 to
  foundation - 14 tableau moves - 9 flips")
- wasm ReplayPlayer gains seek(step): clamped jump to any position,
  rewinding via a stored copy of the recorded deal instead of reparsing
  the replay JSON; replay.js Prev now uses it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 10:59:33 -07:00
46 changed files with 928 additions and 5351 deletions
+7 -56
View File
@@ -2,19 +2,6 @@
# locally, run on every master push and pull request. Until this workflow # locally, run on every master push and pull request. Until this workflow
# existed, nothing in CI ran the test suite at all — a direct push to # existed, nothing in CI ran the test suite at all — a direct push to
# master was entirely unguarded. # master was entirely unguarded.
#
# Build caching (2026-07-13): the Gitea actions cache never restored on
# this instance — every run back through run 597 logged "No cache found"
# even for exact keys saved successfully ("Cache saved successfully") by
# a run an hour earlier, including master→master restores. Until the
# cache server on the runner host is fixed, Swatinem/rust-cache is pure
# overhead here. `rust-host` is a HOST executor (its filesystem persists
# between runs — ~/.cargo and the rustup toolchain already carry over),
# so we get warm builds by pointing CARGO_TARGET_DIR at a persistent
# path on the runner instead of tarring gigabytes through a cache API
# that never returns them. Concurrent runs are safe: cargo serialises
# on the target-dir lock.
name: Test name: Test
on: on:
@@ -41,27 +28,8 @@ on:
workflow_dispatch: workflow_dispatch:
jobs: jobs:
# Seconds-long formatting gate in its own job so a rustfmt slip fails
# here instead of after a 35-minute cold build (run 600 spent its
# whole build budget to report an unformatted file).
fmt:
runs-on: rust-host
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Rust 1.95.0
uses: dtolnay/rust-toolchain@master
with:
toolchain: 1.95.0
components: rustfmt
- name: Format check
run: cargo fmt --check
test: test:
runs-on: rust-host runs-on: rust-host
needs: fmt
# Full debuginfo made the solitaire_engine test-binary link peak past the # Full debuginfo made the solitaire_engine test-binary link peak past the
# runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486. # runner's memory — ld was OOM-killed (signal 9) on runs 447 and 486.
@@ -72,19 +40,10 @@ jobs:
# test binaries concurrently; as the workspace grew (runs 514/516/519) # test binaries concurrently; as the workspace grew (runs 514/516/519)
# two+ simultaneous ld processes OOM-killed the runner again even at # two+ simultaneous ld processes OOM-killed the runner again even at
# line-tables-only. Two jobs keeps at most two links in flight — the # line-tables-only. Two jobs keeps at most two links in flight — the
# compile-throughput cost is small next to the warm build. # compile-throughput cost is small next to the cache-warm build.
#
# CARGO_INCREMENTAL=0: incremental artifacts bloat the persistent
# target dir for little benefit in CI (rust-cache used to set this
# for the same reason).
#
# CARGO_TARGET_DIR: persistent on the runner host — see the header
# comment. The prune step below keeps it from growing unbounded.
env: env:
CARGO_PROFILE_DEV_DEBUG: line-tables-only CARGO_PROFILE_DEV_DEBUG: line-tables-only
CARGO_BUILD_JOBS: '2' CARGO_BUILD_JOBS: '2'
CARGO_INCREMENTAL: '0'
CARGO_TARGET_DIR: /home/runner/.cache/ferrous-solitaire/target
steps: steps:
- name: Checkout - name: Checkout
@@ -94,21 +53,10 @@ jobs:
uses: dtolnay/rust-toolchain@master uses: dtolnay/rust-toolchain@master
with: with:
toolchain: 1.95.0 toolchain: 1.95.0
components: clippy components: clippy, rustfmt
# Toolchain or lockfile bumps strand stale artifacts nothing will - name: Cache cargo build
# ever reuse; reset the dir when it crosses 40 GiB rather than uses: Swatinem/rust-cache@v2
# curating it (a cold rebuild every few weeks is cheaper than the
# bookkeeping).
- name: Prune persistent target dir when oversized
run: |
limit_kb=$((40 * 1024 * 1024))
used_kb=$(du -sk "$CARGO_TARGET_DIR" 2>/dev/null | cut -f1 || echo 0)
echo "persistent target dir: $((used_kb / 1024)) MiB (limit $((limit_kb / 1024)) MiB)"
if [ "${used_kb:-0}" -gt "$limit_kb" ]; then
echo "over limit — clearing for a fresh cold build"
rm -rf "$CARGO_TARGET_DIR"
fi
# Native link deps for the Bevy crates (engine/app/web) on a bare # Native link deps for the Bevy crates (engine/app/web) on a bare
# ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit. # ubuntu runner: ALSA + udev for input/audio, X11 + Wayland for winit.
@@ -119,6 +67,9 @@ jobs:
libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \ libasound2-dev libudev-dev pkg-config libx11-dev libxcursor-dev \
libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev libxrandr-dev libxi-dev libwayland-dev libxkbcommon-dev
- name: Format check
run: cargo fmt --check
# SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB), # SQLX_OFFLINE uses the checked-in `.sqlx/` query cache (no live DB),
# same as the web-e2e workflow's server prebuild. # same as the web-e2e workflow's server prebuild.
- name: Clippy (deny warnings) - name: Clippy (deny warnings)
-94
View File
@@ -6,100 +6,6 @@ project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [Unreleased]
## [0.47.0] — 2026-07-13
### Added
- **"What's new" on update.** The first launch after an update shows a
one-time card summarising what changed — like this one, right now.
Dismiss it and it never repeats; fresh installs never see it. (#180)
- **Situational tips.** Two one-time teaches that fire from play, not a
tour: staring at a stuck board for a while points you at Hint, and an
engaged game quietly mentions the long-press quick-action menu —
unless you've already found it yourself. (#181)
- **UI scale.** Settings → Accessibility now has a UI Scale control
(90% / 100% / 115% / 130%) that resizes every menu, button, and HUD
element live. The table always fits your screen regardless. (#182)
- **Hold `/` for shortcuts** (desktop). A reference card of every
keyboard binding appears while `/` is held. The onboarding tour and
this card now share one binding table, so they can never disagree —
the tour's copy had drifted from reality. (#183)
## [0.46.0] — 2026-07-13
### Added
- **Theme-store previews.** The store modal now shows each theme's
preview image next to its name — the server has advertised them since
the store launched; the client finally fetches them. Thumbnails load
in the background, pop in as they arrive, and are remembered for the
session. (#179)
- **Hint ghost preview.** Asking for a hint now also plays a translucent
copy of the suggested card gliding to its destination (twice, then it
fades) alongside the usual highlights — you see the move, not just the
pieces. Automatically disabled when reduce-motion is on. (#179)
### Changed
- **One toast style, one place.** Queued info banners and instant
celebration/warning/error toasts now share a single bottom-anchored
stack that clears the touch action bar; simultaneous toasts stack
upward instead of overlapping. (#178)
### Fixed
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
players to left/right-click; it now describes drag, double-tap, and
the bottom-bar Hint button. (#178)
- **First launch shows one screen at a time.** On a fresh install the
Home screen no longer spawns underneath the welcome tour; the order is
now onboarding → Home → table. (#178)
## [0.45.0] — 2026-07-13
### Added
- **Thumb-reach action bar on touch (menu redesign Phase F).** The bottom
bar slims to five buttons: an enlarged **Undo · Draw · Hint** trio
(96×64 px targets) between compact Menu and Pause. **Draw is new** — it
draws from the stock exactly like tapping the deck, without the reach to
the top of a tall folded screen. Help, Modes, and New Game moved off the
touch bar (Menu → System, the Home grid, and Home's hero button cover
them). Desktop keeps its seven-button bar unchanged.
- **Hold-to-repeat Undo.** Press and hold Undo to step back repeatedly
(~5.5 undos/s after a short delay) instead of tap-tap-tap. Every step
goes through the normal undo path, so scoring penalties apply as usual.
## [0.44.0] — 2026-07-13
### Added
- **Home is now a real home (menu redesign Phase B).** The mode picker became
a hierarchy: a **Continue** card (mode · elapsed · score) appears while a
game is in progress and returns to the table; a hero **New Game** button
replays your last mode with the current deal options in one tap; deal
options (draw 1/3, a new **winnable-only** toggle, difficulty tiers) moved
into a disclosure under the hero; the six modes sit in a compact symmetric
2×3 grid with descriptions on wide screens; and the stats strip moved to
the bottom. Wide viewports (desktop, unfolded foldables) get a two-pane
layout — launch surfaces left, Continue/daily/stats right. Cancel is now
**Back to table** and only appears while a live game exists. (#175)
### Fixed
- **Save files are self-contained (schema v6).** Like replays in 0.43.3, a
saved game now stores the dealt board via the upstream session serializers
instead of re-dealing from the seed on load; v4/v5 saves still load through
the legacy path and rewrite as v6 on next save. (#171)
### Internal
- **CI is ~9× faster.** Root-caused why the actions cache never restored on
the host-executor runner (per-run workdir paths poisoned the cache version
hash); the test workflow now uses a persistent on-runner target dir, and a
seconds-long `fmt` gate fails formatting mistakes before the build. Warm
full-gate runs: 4m29s, down from ~40 min. (#176)
## [0.43.3] — 2026-07-10 ## [0.43.3] — 2026-07-10
### Fixed ### Fixed
+7 -14
View File
@@ -1,9 +1,7 @@
# Menu UX Redesign — July 2026 # Menu UX Redesign — July 2026
Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
`feat/home-hierarchy` (2026-07-13); F / H and the IM backlog remain open. out of scope — this is about **structure and interaction**, not colors or type.
Visual identity (Terminal / base16-eighties) is settled and out of
scope — this is about **structure and interaction**, not colors or type.
## Diagnosis (from code survey, 2026-07-07) ## Diagnosis (from code survey, 2026-07-07)
@@ -199,13 +197,8 @@ slimming), then **B**, then **F/G/H** as independent follow-ups.
## Open decisions ## Open decisions
1. ~~Settings: tabs vs. sub-pages~~**DECIDED 2026-07-07: tabs.** 1. ~~Settings: tabs vs. sub-pages~~**DECIDED 2026-07-07: tabs.**
2. ~~Deal options: disclosure on Classic card vs. keep global row~~ 2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
**DECIDED 2026-07-09: disclosure on the Classic card / New Game hero.** 3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
3. ~~Time Attack + Seed: top-level cards vs. "More" card~~ tucked under a "More" card?
**DECIDED 2026-07-09: top-level cards, symmetric 2×3 grid.** 4. Stitch mockups for Phase B, or iterate directly in-engine?
4. ~~Stitch mockups for Phase B, or iterate directly in-engine~~ 5. Phase F bottom bar: touch-only (proposed) or also desktop?
**DECIDED 2026-07-09: directly in-engine.**
5. ~~Phase F bottom bar: touch-only vs. also desktop~~
**DECIDED 2026-07-13: touch-only.** Desktop keeps the current top
band; the bar is additive per §3.3 so a later desktop rollout stays
a settings flag away.
+68 -146
View File
@@ -22,17 +22,11 @@ use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// indices for enum variants. No longer loadable — v3 files are discarded. /// indices for enum variants. No longer loadable — v3 files are discarded.
/// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum /// - v4: `saved_moves` uses upstream `KlondikeInstruction` serde with named enum
/// variants (e.g. `"Foundation1"` instead of `0`). /// variants (e.g. `"Foundation1"` instead of `0`).
/// - v5: `score`, `undo_count`, and `recycle_count` are no longer /// - v5 (current): `score`, `undo_count`, and `recycle_count` are no longer
/// persisted. They are derived from the upstream `card_game`/`klondike` session /// persisted. They are derived from the upstream `card_game`/`klondike` session
/// stats, which are rebuilt by replaying `saved_moves` on load. Older files that /// stats, which are rebuilt by replaying `saved_moves` on load. Older files that
/// still carry those keys load fine — the extra fields are ignored. /// still carry those keys load fine — the extra fields are ignored.
/// - v6 (current): `saved_moves` replaced by `recording`, the upstream pub const GAME_STATE_SCHEMA_VERSION: u32 = 5;
/// `card_game` session serialisation carrying the dealt board explicitly.
/// Loading no longer re-deals from `seed`, so in-progress saves survive
/// RNG/upstream upgrades that change the seed→deal mapping (the failure
/// class that PR #170 fixed for replays). v4/v5 files still load through
/// the legacy seed path and are rewritten as v6 on next save.
pub const GAME_STATE_SCHEMA_VERSION: u32 = 6;
/// Default move budget for a solvability check. Matches the winnable-deal retry /// Default move budget for a solvability check. Matches the winnable-deal retry
/// loop in the engine. /// loop in the engine.
@@ -101,8 +95,8 @@ pub enum GameMode {
Difficulty(DifficultyLevel), Difficulty(DifficultyLevel),
} }
/// Output struct for schema v6 serialisation. `recording` is the upstream /// Output struct for schema v4 serialisation. `saved_moves` uses upstream
/// session serialisation (config + dealt board + instruction list). /// `KlondikeInstruction` serde, which produces named enum variants.
#[derive(Debug, Clone, Serialize)] #[derive(Debug, Clone, Serialize)]
struct PersistedGameState { struct PersistedGameState {
pub draw_mode: DrawStockConfig, pub draw_mode: DrawStockConfig,
@@ -111,16 +105,15 @@ struct PersistedGameState {
pub seed: u64, pub seed: u64,
pub take_from_foundation: bool, pub take_from_foundation: bool,
pub schema_version: u32, pub schema_version: u32,
pub recording: SessionRecording, pub saved_moves: Vec<KlondikeInstruction>,
} }
/// Input struct that accepts schema v4, v5, and v6 save formats. /// Input struct that accepts schema v4 and v5 `saved_moves` formats.
/// ///
/// v6 files carry `recording` (the upstream session serialisation, deal /// `saved_moves` is deserialised directly as upstream `KlondikeInstruction`
/// included); v4/v5 files carry `saved_moves` and rebuild the deal from /// (named-variant serde). `score`, `undo_count`, and `recycle_count` are
/// `seed`. `score`, `undo_count`, and `recycle_count` are intentionally /// intentionally absent: all three are rebuilt by replaying the instruction
/// absent: all three are rebuilt by replaying the instruction history /// history through the upstream session stats. Older v4 save files still carry
/// through the upstream session stats. Older v4 save files still carry
/// those keys; serde ignores them. /// those keys; serde ignores them.
#[derive(Debug, Clone, Deserialize)] #[derive(Debug, Clone, Deserialize)]
struct PersistedGameStateIn { struct PersistedGameStateIn {
@@ -133,12 +126,7 @@ struct PersistedGameStateIn {
pub take_from_foundation: bool, pub take_from_foundation: bool,
#[serde(default = "schema_v1")] #[serde(default = "schema_v1")]
pub schema_version: u32, pub schema_version: u32,
/// v4/v5 only. Replayed against a seed-dealt board.
#[serde(default)]
pub saved_moves: Vec<KlondikeInstruction>, pub saved_moves: Vec<KlondikeInstruction>,
/// v6 only. Carries the dealt board explicitly.
#[serde(default)]
pub recording: Option<SessionRecording>,
} }
#[cfg(feature = "test-support")] #[cfg(feature = "test-support")]
@@ -234,7 +222,7 @@ impl Serialize for GameState {
seed: self.seed, seed: self.seed,
take_from_foundation: self.take_from_foundation, take_from_foundation: self.take_from_foundation,
schema_version: GAME_STATE_SCHEMA_VERSION, schema_version: GAME_STATE_SCHEMA_VERSION,
recording: self.recording(), saved_moves: self.saved_moves(),
} }
.serialize(serializer) .serialize(serializer)
} }
@@ -244,51 +232,34 @@ impl<'de> Deserialize<'de> for GameState {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let persisted = PersistedGameStateIn::deserialize(deserializer)?; let persisted = PersistedGameStateIn::deserialize(deserializer)?;
// Accept v6 (current, recording-based), plus v4/v5 (legacy seed-dealt // Accept v4 (upstream named-variant serde) and v5 (current, derived
// saves — loadable while the seed→deal mapping they were written // stats). v3 (legacy u8-index format) and all others are rejected.
// under still holds; rewritten as v6 on the next save). v3 (legacy match persisted.schema_version {
// u8-index format) and all others are rejected. 4 | 5 => {}
let (mut game, instructions) = match persisted.schema_version {
6 => {
let Some(recording) = persisted.recording else {
return Err(serde::de::Error::custom(
"v6 save file is missing the session recording",
));
};
// The dealt board and session config come from the recording
// itself; `seed` is presentation metadata only.
Self::from_recording(&recording, persisted.seed, persisted.mode)
}
4 | 5 => (
Self {
mode: persisted.mode,
elapsed_seconds: 0,
seed: persisted.seed,
take_from_foundation: true,
session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
},
persisted.saved_moves,
),
v => { v => {
return Err(serde::de::Error::custom(format!( return Err(serde::de::Error::custom(format!(
"unsupported GameState schema version {v}" "unsupported GameState schema version {v}"
))); )));
} }
}; }
game.mode = persisted.mode;
game.elapsed_seconds = persisted.elapsed_seconds;
game.take_from_foundation = persisted.take_from_foundation;
// Replay the saved instruction history with validation — a tampered let mut game = Self {
// or corrupt file must surface an error, not a silently wrong board. mode: persisted.mode,
// The upstream session tracks score components and recycle_count as elapsed_seconds: persisted.elapsed_seconds,
// it processes each move, so the derived stats are correct once seed: persisted.seed,
// replay completes. `undo_count()` resets to 0 across save/load take_from_foundation: persisted.take_from_foundation,
// because undone moves are not part of the saved forward history. session: Self::new_session(persisted.seed, persisted.draw_mode),
#[cfg(feature = "test-support")]
test_pile_state: None,
};
// Replay the saved instruction history. The upstream session tracks
// score components and recycle_count as it processes each move, so the
// derived stats are correct once replay completes. `undo_count()` resets
// to 0 across save/load because undone moves are not part of the saved
// forward history.
let replay_config = Self::replay_config(persisted.draw_mode); let replay_config = Self::replay_config(persisted.draw_mode);
for instruction in instructions { for instruction in persisted.saved_moves {
if !game if !game
.session .session
.state() .state()
@@ -490,6 +461,32 @@ impl GameState {
self.session.stats().stats().recycle_count() self.session.stats().stats().recycle_count()
} }
/// Number of cards moved onto foundations this game, read from the
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
/// not rolled back on undo.
pub fn move_to_foundation_count(&self) -> u32 {
self.session.stats().stats().move_to_foundation_count()
}
/// Number of stacks moved onto tableaus (from stock or another tableau)
/// this game, read from the upstream session stats. Cumulative — not
/// rolled back on undo.
pub fn move_to_tableau_count(&self) -> u32 {
self.session.stats().stats().move_to_tableau_count()
}
/// Number of cards taken back off a foundation this game, read from the
/// upstream session stats. Cumulative — not rolled back on undo.
pub fn move_from_foundation_count(&self) -> u32 {
self.session.stats().stats().move_from_foundation_count()
}
/// Number of face-down cards revealed (flipped up) this game, read from
/// the upstream session stats. Cumulative — not rolled back on undo.
pub fn flip_up_count(&self) -> u32 {
self.session.stats().stats().flip_up_bonus_count()
}
/// Total moves made this game (draws, recycles, and card moves), derived /// Total moves made this game (draws, recycles, and card moves), derived
/// from the session's instruction history length. /// from the session's instruction history length.
pub fn move_count(&self) -> u32 { pub fn move_count(&self) -> u32 {
@@ -553,6 +550,16 @@ impl GameState {
KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation) KlondikeAdapter::config_for(self.draw_mode(), self.take_from_foundation)
} }
/// Collects the session instruction history as upstream types for schema v4
/// serialisation.
fn saved_moves(&self) -> Vec<KlondikeInstruction> {
self.session
.history()
.iter()
.map(|snapshot| *snapshot.instruction())
.collect()
}
/// Returns the deterministic instruction history for the current deal as /// Returns the deterministic instruction history for the current deal as
/// upstream [`KlondikeInstruction`] values. /// upstream [`KlondikeInstruction`] values.
/// ///
@@ -1716,91 +1723,6 @@ mod tests {
assert_eq!(replayed.move_count(), game.move_count()); assert_eq!(replayed.move_count(), game.move_count());
} }
/// v6 save files carry the deal in the recording; the `seed` field is
/// presentation metadata. Corrupting it must not change the loaded board.
#[test]
fn v6_save_load_ignores_seed_for_dealing() {
let game = game_with_some_moves(51);
let mut value = serde_json::to_value(&game).expect("serialise");
assert_eq!(value["schema_version"], 6);
value["seed"] = serde_json::Value::from(0xDEAD_BEEF_u64);
let loaded: GameState =
serde_json::from_value(value).expect("v6 save with wrong seed must still load");
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.waste_cards(), game.waste_cards());
for index in 0..7 {
let t = GameState::tableau_from_index(index).expect("tableau index");
assert_eq!(
loaded.pile(KlondikePile::Tableau(t)),
game.pile(KlondikePile::Tableau(t)),
"tableau {index} differs"
);
}
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.score(), game.score());
}
/// Legacy v5 files (seed + saved_moves, no recording) must still load
/// through the seed-dealt path until players resave as v6.
#[test]
fn v5_legacy_save_still_loads() {
let game = game_with_some_moves(145);
let v5 = serde_json::json!({
"draw_mode": game.draw_mode(),
"mode": game.mode,
"elapsed_seconds": 12,
"seed": game.seed,
"take_from_foundation": true,
"schema_version": 5,
"saved_moves": game.instruction_history(),
});
let loaded: GameState =
serde_json::from_value(v5).expect("v5 save must load via the legacy seed path");
assert_eq!(loaded.move_count(), game.move_count());
assert_eq!(loaded.stock_cards(), game.stock_cards());
assert_eq!(loaded.elapsed_seconds, 12);
}
/// A v6 file without the recording payload is malformed and must be
/// rejected with an error rather than silently re-dealt from the seed.
#[test]
fn v6_save_without_recording_is_rejected() {
let game = GameState::new(7, DrawStockConfig::DrawOne);
let mut value = serde_json::to_value(&game).expect("serialise");
value.as_object_mut().expect("object").remove("recording");
let err = serde_json::from_value::<GameState>(value)
.expect_err("v6 save without recording must fail");
assert!(err.to_string().contains("recording"), "got: {err}");
}
/// A recording whose instruction list is invalid for its own deal must
/// be rejected on load — tampered or corrupt files surface an error,
/// never a silently wrong board.
#[test]
fn v6_save_with_invalid_history_is_rejected() {
use klondike::{DstFoundation, Foundation, KlondikePile};
// A foundation move from an empty tableau is never legal on a fresh
// deal's first instruction slot for this source shape.
let bogus = KlondikeInstruction::DstFoundation(DstFoundation {
src: KlondikePile::Foundation(Foundation::Foundation1),
foundation: Foundation::Foundation2,
});
let recording =
SessionRecording::from_instructions_unchecked(7, DrawStockConfig::DrawOne, [bogus]);
let v6 = serde_json::json!({
"draw_mode": DrawStockConfig::DrawOne,
"mode": GameMode::Classic,
"elapsed_seconds": 0,
"seed": 7,
"take_from_foundation": true,
"schema_version": 6,
"recording": recording,
});
let err =
serde_json::from_value::<GameState>(v6).expect_err("invalid history must be rejected");
assert!(err.to_string().contains("invalid"), "got: {err}");
}
#[test] #[test]
fn from_recording_of_fresh_deal_returns_empty_instructions() { fn from_recording_of_fresh_deal_returns_empty_instructions() {
let game = GameState::new(7, DrawStockConfig::DrawThree); let game = GameState::new(7, DrawStockConfig::DrawThree);
+1 -52
View File
@@ -9,10 +9,7 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use solitaire_core::{ use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
DrawStockConfig,
game_state::{DifficultyLevel, GameMode},
};
const SETTINGS_FILE_NAME: &str = "settings.json"; const SETTINGS_FILE_NAME: &str = "settings.json";
@@ -251,38 +248,6 @@ pub struct Settings {
/// cleanly to `None` via `#[serde(default)]`. /// cleanly to `None` via `#[serde(default)]`.
#[serde(default)] #[serde(default)]
pub last_difficulty: Option<DifficultyLevel>, pub last_difficulty: Option<DifficultyLevel>,
/// Mode of the last game the player launched from the home overlay.
/// The home hero "New Game" button replays this mode with one tap.
/// Older `settings.json` files written before this field existed
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
#[serde(default)]
pub last_mode: GameMode,
/// The release version whose "What's new" card the player has already
/// seen (e.g. `"0.46.0"`). Empty on installs that predate the card,
/// which correctly reads as "there is news to show" after an upgrade;
/// fresh installs stamp it silently when onboarding completes. Older
/// `settings.json` files deserialize cleanly to `""` via
/// `#[serde(default)]`.
#[serde(default)]
pub last_seen_whats_new: String,
/// `true` once the one-shot "Stuck? Try a hint" contextual tip has
/// fired (or been suppressed as unnecessary). Phase I teach: fired
/// by the situation, shown once, like `shown_achievement_onboarding`.
#[serde(default)]
pub shown_stall_hint_tip: bool,
/// `true` once the one-shot radial-menu contextual tip has fired —
/// or the player has already opened the radial menu on their own,
/// which marks the tip as unnecessary without showing it.
#[serde(default)]
pub shown_radial_menu_tip: bool,
/// Global UI scale multiplier applied to all UI chrome (HUD, modals,
/// action bar) — the table itself stays window-fit via
/// `compute_layout`. Cycles through 0.9 / 1.0 / 1.15 / 1.3 in
/// Settings → Accessibility; clamped to `[UI_SCALE_MIN,
/// UI_SCALE_MAX]` by [`Settings::sanitized`]. Older `settings.json`
/// files deserialize cleanly to `1.0` via the serde default.
#[serde(default = "default_ui_scale")]
pub ui_scale: f32,
/// Custom public name displayed on the leaderboard. When `None`, the /// Custom public name displayed on the leaderboard. When `None`, the
/// player's server `username` is used instead. Trimmed to 32 characters /// player's server `username` is used instead. Trimmed to 32 characters
/// before submission. Older `settings.json` files written before this /// before submission. Older `settings.json` files written before this
@@ -391,16 +356,6 @@ fn default_replay_move_interval_secs() -> f32 {
0.45 0.45
} }
/// Lowest / highest UI scale the settings row offers; anything outside
/// (hand-edited settings.json) clamps here on load.
pub const UI_SCALE_MIN: f32 = 0.9;
/// See [`UI_SCALE_MIN`].
pub const UI_SCALE_MAX: f32 = 1.3;
fn default_ui_scale() -> f32 {
1.0
}
fn default_matomo_site_id() -> u32 { fn default_matomo_site_id() -> u32 {
1 1
} }
@@ -460,11 +415,6 @@ impl Default for Settings {
disable_smart_default_size: false, disable_smart_default_size: false,
replay_move_interval_secs: default_replay_move_interval_secs(), replay_move_interval_secs: default_replay_move_interval_secs(),
last_difficulty: None, last_difficulty: None,
last_mode: GameMode::Classic,
last_seen_whats_new: String::new(),
shown_stall_hint_tip: false,
shown_radial_menu_tip: false,
ui_scale: default_ui_scale(),
leaderboard_display_name: None, leaderboard_display_name: None,
leaderboard_opted_in: false, leaderboard_opted_in: false,
take_from_foundation: true, take_from_foundation: true,
@@ -500,7 +450,6 @@ impl Settings {
replay_move_interval_secs: self replay_move_interval_secs: self
.replay_move_interval_secs .replay_move_interval_secs
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS), .clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
ui_scale: self.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX),
selected_theme_id, selected_theme_id,
..self ..self
} }
+5 -10
View File
@@ -477,7 +477,7 @@ mod tests {
/// again must reproduce byte-identical JSON. `undo_count` deliberately resets /// again must reproduce byte-identical JSON. `undo_count` deliberately resets
/// to 0 on load because only the forward instruction history is persisted. /// to 0 on load because only the forward instruction history is persisted.
#[test] #[test]
fn game_state_v6_mid_game_round_trip() { fn game_state_v5_mid_game_round_trip() {
use solitaire_core::KlondikeInstruction; use solitaire_core::KlondikeInstruction;
use solitaire_core::game_state::GameState; use solitaire_core::game_state::GameState;
@@ -518,16 +518,11 @@ mod tests {
save_game_state_to(&path, &gs).expect("save"); save_game_state_to(&path, &gs).expect("save");
// Verify the file carries the v6 schema marker and the recording. // Verify the file carries the v5 schema marker.
let json = fs::read_to_string(&path).expect("read json"); let json = fs::read_to_string(&path).expect("read json");
let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse saved json");
assert_eq!(
parsed["schema_version"], 6,
"saved file must use schema version 6",
);
assert!( assert!(
parsed["recording"].is_object(), json.contains("\"schema_version\"") && json.contains('5'),
"saved file must embed the session recording", "saved file must use schema version 5",
); );
let loaded = let loaded =
@@ -535,7 +530,7 @@ mod tests {
// The forward instruction history round-trips, so the reconstructed board // The forward instruction history round-trips, so the reconstructed board
// re-serialises to byte-identical JSON. // re-serialises to byte-identical JSON.
let path_reload = gs_path("v6_mid_game_reload"); let path_reload = gs_path("v5_mid_game_reload");
let _ = fs::remove_file(&path_reload); let _ = fs::remove_file(&path_reload);
save_game_state_to(&path_reload, &loaded).expect("re-save loaded"); save_game_state_to(&path_reload, &loaded).expect("re-save loaded");
assert_eq!( assert_eq!(
-40
View File
@@ -19,10 +19,6 @@ use thiserror::Error;
/// so downloading it is pure waste. /// so downloading it is pure waste.
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024; pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
/// Hard cap on a preview PNG download — previews are small decorative
/// thumbnails; anything past this is a misconfigured server.
pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024;
/// Errors surfaced by [`ThemeStoreClient`]. /// Errors surfaced by [`ThemeStoreClient`].
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum ThemeStoreError { pub enum ThemeStoreError {
@@ -114,42 +110,6 @@ impl ThemeStoreClient {
verify_archive(&bytes, entry)?; verify_archive(&bytes, entry)?;
Ok(bytes.to_vec()) Ok(bytes.to_vec())
} }
/// Fetch the preview PNG the catalog advertises for `entry`.
///
/// Returns `Http(404)` when the entry carries no `preview_url` —
/// the same shape the server answers with when the file is absent,
/// so callers only handle one "no preview" case. Previews are
/// decorative; unlike archives they carry no checksum, only the
/// [`MAX_PREVIEW_BYTES`] size cap.
pub async fn fetch_preview(
&self,
entry: &ThemeCatalogEntry,
) -> Result<Vec<u8>, ThemeStoreError> {
let Some(path) = entry.preview_url.as_deref() else {
return Err(ThemeStoreError::Http(404));
};
let resp = self
.client
.get(format!("{}{}", self.base_url, path))
.send()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if !resp.status().is_success() {
return Err(ThemeStoreError::Http(resp.status().as_u16()));
}
let bytes = resp
.bytes()
.await
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
if bytes.len() as u64 > MAX_PREVIEW_BYTES {
return Err(ThemeStoreError::Oversized {
expected: MAX_PREVIEW_BYTES,
got: bytes.len() as u64,
});
}
Ok(bytes.to_vec())
}
} }
/// Checks downloaded `bytes` against the catalog `entry`'s declared /// Checks downloaded `bytes` against the catalog `entry`'s declared
+37 -161
View File
@@ -29,9 +29,7 @@ use crate::events::{
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
use crate::pause_plugin::PausedResource; use crate::pause_plugin::PausedResource;
use crate::platform::USE_TOUCH_UI_LAYOUT;
use crate::progress_plugin::LevelUpEvent; use crate::progress_plugin::LevelUpEvent;
use crate::safe_area::SafeAreaAnchoredBottom;
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource}; use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
use crate::time_attack_plugin::TimeAttackEndedEvent; use crate::time_attack_plugin::TimeAttackEndedEvent;
use crate::ui_theme::{ use crate::ui_theme::{
@@ -162,71 +160,6 @@ pub struct ActiveToast {
/// Duration of each queued info-toast in seconds. /// Duration of each queued info-toast in seconds.
const QUEUED_TOAST_SECS: f32 = 2.5; const QUEUED_TOAST_SECS: f32 = 2.5;
/// Marker on the persistent bottom-anchored flex column every toast
/// spawns into (Phase H). Stacking through one container gives queued
/// and immediate toasts a single shared anchor — simultaneous toasts
/// stack upward instead of relying on the old staggered-percentage
/// anchors to dodge each other.
#[derive(Component, Debug)]
pub struct ToastStackRoot;
/// Marker on every toast card node (both paths). Freshly spawned toasts
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
/// of latency, imperceptible at toast timescales, in exchange for the 14
/// toast handlers keeping their `Commands`-only signatures.
#[derive(Component, Debug)]
pub struct ToastNode;
/// Logical-pixel gap between the screen bottom and the toast stack,
/// before safe-area insets. Clears the floating glass tab bar on touch
/// (its margin + height + breathing room); desktop's docked bar needs less.
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT {
crate::hud_plugin::tab_bar::TAB_BAR_CLEARANCE_PX
} else {
72.0
};
/// Spawns the persistent [`ToastStackRoot`] container at startup.
fn spawn_toast_stack_root(mut commands: Commands) {
commands.spawn((
ToastStackRoot,
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
left: Val::Px(0.0),
width: Val::Percent(100.0),
// Newest toast sits nearest the bottom edge; older ones
// push upward.
flex_direction: FlexDirection::ColumnReverse,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
..default()
},
SafeAreaAnchoredBottom {
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
},
ZIndex(Z_TOAST),
));
}
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
/// reveals them. No-op when every toast is already adopted.
fn adopt_toasts_into_stack(
mut commands: Commands,
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
root: Query<Entity, With<ToastStackRoot>>,
) {
let Ok(root) = root.single() else {
return;
};
for toast in &orphans {
commands
.entity(toast)
.insert((ChildOf(root), Visibility::Inherited));
}
}
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence. /// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
pub struct AnimationPlugin; pub struct AnimationPlugin;
@@ -252,7 +185,7 @@ impl Plugin for AnimationPlugin {
.init_resource::<EffectiveSlideDuration>() .init_resource::<EffectiveSlideDuration>()
.init_resource::<ToastQueue>() .init_resource::<ToastQueue>()
.init_resource::<ActiveToast>() .init_resource::<ActiveToast>()
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root)) .add_systems(Startup, init_slide_duration)
.add_systems( .add_systems(
Update, Update,
( (
@@ -273,7 +206,6 @@ impl Plugin for AnimationPlugin {
handle_warning_toast, handle_warning_toast,
tick_toasts, tick_toasts,
(enqueue_toasts, drive_toast_display).chain(), (enqueue_toasts, drive_toast_display).chain(),
adopt_toasts_into_stack,
) )
.after(GameMutation), .after(GameMutation),
); );
@@ -705,14 +637,26 @@ impl ToastVariant {
} }
} }
/// Spawns a `ToastEntity` for the queued toast system. /// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
/// ///
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed /// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
/// by [`InfoToastEvent`] which is by definition neutral system info. /// by [`InfoToastEvent`] which is by definition neutral system info.
/// Variants other than `Info` belong on the immediate-fire path /// Variants other than `Info` belong on the immediate-fire path
/// ([`spawn_toast`]) where the call site knows the semantic intent. /// ([`spawn_toast`]) where the call site knows the semantic intent.
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity { fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info) spawn_toast_node(
commands,
ToastEntity,
message,
ToastVariant::Info,
// Slightly taller anchor than the immediate-fire path so a
// queued info banner doesn't collide with a celebration toast
// fired in the same frame.
Val::Percent(6.0),
Val::Percent(15.0),
Val::Percent(70.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
)
} }
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) { fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
@@ -800,6 +744,12 @@ fn spawn_toast(
(ToastOverlay, ToastTimer(duration_secs)), (ToastOverlay, ToastTimer(duration_secs)),
message, message,
variant, variant,
// Sits above the queued banner so a celebration toast spawned
// alongside a queued info message remains readable.
Val::Percent(14.0),
Val::Percent(25.0),
Val::Percent(50.0),
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
); );
} }
@@ -816,25 +766,31 @@ fn spawn_toast(
/// rungs; 18 is the closest rung that preserves the scale invariants /// rungs; 18 is the closest rung that preserves the scale invariants
/// tested in `ui_theme::tests`. /// tested in `ui_theme::tests`.
/// - [`RADIUS_MD`] corners. /// - [`RADIUS_MD`] corners.
/// /// - Bottom-anchored absolute position; `bottom_pct` differs between
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns /// queued and immediate paths so they layer instead of overlap.
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it // The 8-argument signature is intentional — these are the per-toast
/// into the shared bottom-anchored column — one anchor for every toast, // layout values that genuinely differ between the queued and fire-and-
/// simultaneous toasts stack instead of overlapping. // forget call sites. A struct wrapper would just rename the same data.
#[allow(clippy::too_many_arguments)]
fn spawn_toast_node<B: Bundle>( fn spawn_toast_node<B: Bundle>(
commands: &mut Commands, commands: &mut Commands,
bundle: B, bundle: B,
message: String, message: String,
variant: ToastVariant, variant: ToastVariant,
bottom_pct: Val,
left_pct: Val,
width_pct: Val,
padding: UiRect,
) -> Entity { ) -> Entity {
commands commands
.spawn(( .spawn((
bundle, bundle,
ToastNode,
Visibility::Hidden,
Node { Node {
max_width: Val::Percent(70.0), position_type: PositionType::Absolute,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3), left: left_pct,
bottom: bottom_pct,
width: width_pct,
padding,
justify_content: JustifyContent::Center, justify_content: JustifyContent::Center,
align_items: AlignItems::Center, align_items: AlignItems::Center,
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
@@ -843,6 +799,7 @@ fn spawn_toast_node<B: Bundle>(
}, },
BackgroundColor(BG_ELEVATED), BackgroundColor(BG_ELEVATED),
BorderColor::all(variant.border_color()), BorderColor::all(variant.border_color()),
ZIndex(Z_TOAST),
)) ))
.with_children(|b| { .with_children(|b| {
b.spawn(( b.spawn((
@@ -1358,85 +1315,4 @@ mod tests {
fn cascade_duration_instant_is_zero() { fn cascade_duration_instant_is_zero() {
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0); assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
} }
// -----------------------------------------------------------------------
// Phase H: unified toast stack
// -----------------------------------------------------------------------
/// Both toast paths must end up as visible children of the single
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
#[test]
fn queued_and_immediate_toasts_stack_under_one_root() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update(); // Startup: spawns the stack root.
// One immediate (error) toast + one queued (info) toast in the
// same frame — the exact collision case the old staggered
// anchors existed to dodge.
use solitaire_core::{KlondikePile, Tableau};
app.world_mut().write_message(MoveRejectedEvent {
from: KlondikePile::Tableau(Tableau::Tableau1),
to: KlondikePile::Tableau(Tableau::Tableau2),
count: 1,
});
app.world_mut()
.write_message(InfoToastEvent("stacked info".to_string()));
app.update(); // handlers spawn both toasts (hidden, unparented)
app.update(); // adopt_toasts_into_stack re-parents + reveals
let root = app
.world_mut()
.query_filtered::<Entity, With<ToastStackRoot>>()
.single(app.world())
.expect("exactly one ToastStackRoot must exist");
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
.world_mut()
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
.iter(app.world())
.collect();
assert_eq!(
toasts.len(),
2,
"both the immediate and the queued toast must be adopted"
);
for (entity, child_of, visibility) in toasts {
assert_eq!(
child_of.parent(),
root,
"toast {entity} must be a child of the shared stack root"
);
assert_eq!(
*visibility,
Visibility::Inherited,
"adopted toast {entity} must be revealed"
);
}
}
/// Toast nodes must not carry their own absolute positioning — the
/// stack root owns layout (regression guard against reintroducing
/// per-path anchors).
#[test]
fn toast_nodes_have_no_absolute_position() {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
app.update();
app.world_mut()
.write_message(WarningToastEvent("layout check".to_string()));
app.update();
let node = app
.world_mut()
.query_filtered::<&Node, With<ToastNode>>()
.single(app.world())
.expect("warning toast must spawn a ToastNode");
assert_eq!(
node.position_type,
PositionType::Relative,
"toast nodes are flex children of the stack, not absolute overlays"
);
}
} }
-127
View File
@@ -1,127 +0,0 @@
//! SVG builders for the floating tab bar's action icons.
//!
//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
//! is missing on Android entirely), so the icons are authored here as tiny
//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
//! as the card themes (`assets::rasterize_svg`).
//!
//! Icons are drawn in **white** on a transparent background inside a
//! 24-unit logical box; the spawn site tints them to the theme colour via
//! `ImageNode::color` (a per-node multiply), so one texture serves every
//! button state.
use bevy::image::Image;
use bevy::math::UVec2;
use super::svg_loader::{SvgLoaderError, rasterize_svg};
/// Rasterisation size for one tab-bar icon texture. Icons display at
/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
/// factor (and any future 3× phone) while staying trivially small.
pub const HUD_ICON_TEXTURE_PX: u32 = 96;
/// The five actions the floating touch tab bar can show.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HudIcon {
/// Counter-clockwise "take back" arrow.
Undo,
/// Two offset card outlines — draw from the stock.
Draw,
/// Lightbulb — highlight a suggested move.
Hint,
/// Two vertical bars.
Pause,
/// Three-line hamburger — the floating menu button.
Menu,
}
impl HudIcon {
/// Build the icon's SVG document. All icons share one visual language:
/// 2-unit white strokes, round caps, transparent fill, 24-unit box.
pub fn svg(self) -> String {
let body = match self {
// Arrow head pointing left into a half-circle sweeping back
// over the top — the conventional undo mark.
HudIcon::Undo => concat!(
r##"<path d="M 8 5 L 4 9 L 8 13"/>"##,
r##"<path d="M 4 9 H 14 A 5.5 5.5 0 0 1 14 20 H 8"/>"##,
),
// Back card peeks out behind the front card; the front card is
// drawn second so its stroke reads on top.
HudIcon::Draw => concat!(
r##"<rect x="8.5" y="3" width="11" height="15" rx="2"/>"##,
r##"<rect x="4" y="6.5" width="11" height="15" rx="2"/>"##,
),
// Bulb dome with a flat lamp base beneath.
HudIcon::Hint => concat!(
r##"<path d="M 9 15.5 A 6.5 6.5 0 1 1 15 15.5 V 18 H 9 Z"/>"##,
r##"<path d="M 10 21 H 14"/>"##,
),
HudIcon::Pause => concat!(
r##"<path d="M 9 6 V 18"/>"##,
r##"<path d="M 15 6 V 18"/>"##,
),
HudIcon::Menu => concat!(
r##"<path d="M 4 6.5 H 20"/>"##,
r##"<path d="M 4 12 H 20"/>"##,
r##"<path d="M 4 17.5 H 20"/>"##,
),
};
format!(
concat!(
r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">"##,
r##"<g fill="none" stroke="#ffffff" stroke-width="2" "##,
r##"stroke-linecap="round" stroke-linejoin="round">{body}</g>"##,
r##"</svg>"##,
),
body = body
)
}
/// Rasterise this icon into a Bevy [`Image`] at
/// [`HUD_ICON_TEXTURE_PX`] square.
pub fn rasterize(self) -> Result<Image, SvgLoaderError> {
rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX))
}
}
#[cfg(test)]
mod tests {
use super::*;
const ALL: [HudIcon; 5] = [
HudIcon::Undo,
HudIcon::Draw,
HudIcon::Hint,
HudIcon::Pause,
HudIcon::Menu,
];
/// Every icon must survive the real usvg/resvg pipeline at the real
/// texture size — a malformed path here would otherwise only surface
/// as a blank button on device.
#[test]
fn every_icon_rasterizes_at_texture_size() {
for icon in ALL {
let img = icon.rasterize().expect("icon SVG must rasterise");
assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
}
}
/// Rasterised icons must contain visible (non-transparent) pixels —
/// guards against a stroke accidentally drawn outside the viewBox.
#[test]
fn every_icon_has_visible_pixels() {
for icon in ALL {
let img = icon.rasterize().expect("icon SVG must rasterise");
let data = img.data.as_ref().expect("rasterised image has data");
let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
assert!(
opaque > 100,
"{icon:?} renders only {opaque} visible pixels"
);
}
}
}
-1
View File
@@ -5,7 +5,6 @@
//! custom `AssetSource` implementations used by the theme system. //! custom `AssetSource` implementations used by the theme system.
pub mod card_face_svg; pub mod card_face_svg;
pub mod hud_icon_svg;
pub mod icon_svg; pub mod icon_svg;
pub mod sources; pub mod sources;
pub mod svg_loader; pub mod svg_loader;
-194
View File
@@ -1,194 +0,0 @@
//! Hold-`/` hotkey cheat sheet (Phase J).
//!
//! While `/` is held on a keyboard platform, a lightweight overlay
//! lists every binding from [`crate::hotkeys::HOTKEYS`] — the same
//! table the onboarding slide teaches from, so the two can never
//! disagree. Releasing the key hides it instantly; it never captures
//! input, never pauses the game, and never spawns while a modal owns
//! the screen (which also keeps it out of the seed-entry text field's
//! way).
//!
//! Not a `spawn_modal` modal on purpose: modals are sticky and guarded;
//! this is a momentary reference card, closer to a tooltip than a
//! dialog.
use bevy::input::ButtonInput;
use bevy::prelude::*;
use crate::font_plugin::FontResource;
use crate::hotkeys::HOTKEYS;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, HighContrastBorder, RADIUS_MD, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_TOOLTIP,
};
/// Marker on the cheat-sheet overlay root.
#[derive(Component, Debug)]
pub struct CheatSheetOverlay;
/// Registers the hold-`/` driver. Inert on touch-first builds.
pub struct CheatSheetPlugin;
impl Plugin for CheatSheetPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ButtonInput<KeyCode>>()
.add_systems(Update, drive_cheat_sheet);
}
}
/// Shows the overlay while `/` is held (and no modal owns the screen);
/// hides it the frame the key releases.
fn drive_cheat_sheet(
keys: Res<ButtonInput<KeyCode>>,
scrims: Query<(), With<ModalScrim>>,
existing: Query<Entity, With<CheatSheetOverlay>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
if !SHOW_KEYBOARD_ACCELERATORS {
return;
}
let held = keys.pressed(KeyCode::Slash);
if held && existing.is_empty() && scrims.is_empty() {
spawn_cheat_sheet(&mut commands, font_res.as_deref());
} else if !held {
for entity in &existing {
commands.entity(entity).despawn();
}
}
}
fn spawn_cheat_sheet(commands: &mut Commands, font_res: Option<&FontResource>) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_header = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY,
..default()
};
let font_keys = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
let font_desc = TextFont {
font: font_handle,
font_size: TYPE_CAPTION,
..default()
};
commands
.spawn((
CheatSheetOverlay,
Node {
position_type: PositionType::Absolute,
right: Val::Px(16.0),
top: Val::Percent(8.0),
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_1,
padding: UiRect::all(VAL_SPACE_4),
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
max_height: Val::Percent(84.0),
overflow: Overflow::scroll_y(),
..default()
},
BackgroundColor(BG_ELEVATED),
BorderColor::all(BORDER_STRONG),
HighContrastBorder::with_default(BORDER_STRONG),
GlobalZIndex(Z_TOOLTIP),
))
.with_children(|panel| {
panel.spawn((
Text::new("Keyboard shortcuts"),
font_header.clone(),
TextColor(TEXT_PRIMARY),
Node {
margin: UiRect::bottom(VAL_SPACE_2),
..default()
},
));
for row in HOTKEYS {
panel
.spawn(Node {
flex_direction: FlexDirection::Row,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|line| {
line.spawn((
Text::new(row.keys),
font_keys.clone(),
TextColor(ACCENT_PRIMARY),
Node {
min_width: Val::Px(110.0),
..default()
},
));
line.spawn((
Text::new(row.description),
font_desc.clone(),
TextColor(TEXT_SECONDARY),
));
});
}
});
}
#[cfg(test)]
mod tests {
use super::*;
fn app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(CheatSheetPlugin);
app.update();
app
}
fn overlay_count(app: &mut App) -> usize {
app.world_mut()
.query::<&CheatSheetOverlay>()
.iter(app.world())
.count()
}
#[test]
fn holding_slash_shows_and_release_hides() {
let mut app = app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Slash);
app.update();
assert_eq!(overlay_count(&mut app), 1, "held / must show the sheet");
// Still held on later frames: exactly one overlay, no stacking.
app.update();
assert_eq!(overlay_count(&mut app), 1);
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.release(KeyCode::Slash);
app.update();
app.update();
assert_eq!(overlay_count(&mut app), 0, "release must hide the sheet");
}
#[test]
fn suppressed_while_a_modal_is_open() {
let mut app = app();
app.world_mut().spawn(ModalScrim);
app.update();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::Slash);
app.update();
assert_eq!(
overlay_count(&mut app),
0,
"the sheet must not spawn over a modal (or into a text field)"
);
}
}
@@ -1,309 +0,0 @@
//! Contextual one-time tips (Phase I of the 2026-07 UI redesign).
//!
//! Everything the first-run onboarding doesn't cover is invisible until
//! stumbled upon: hint cycling, the radial quick-action menu. These
//! tips fire **from the situation, not a tour** — each exactly once,
//! recorded in `Settings` like `shown_achievement_onboarding`:
//!
//! - **Stall tip** — after [`STALL_TIP_SECS`] with no board change in an
//! active game, an info toast points at Hint. A player staring at a
//! stuck board is the one moment the tip is welcome.
//! - **Radial tip** — once a game reaches [`RADIAL_TIP_MIN_MOVES`] moves
//! (an engaged player) and the radial menu has never been opened, an
//! info toast teaches the long-press / right-click gesture. Opening
//! the radial organically marks the tip as unnecessary — it is never
//! shown to someone who already knows.
//!
//! Tips render through the queued [`InfoToastEvent`] path, so they share
//! the unified toast stack and never interrupt play.
use bevy::prelude::*;
use solitaire_data::save_settings_to;
use crate::events::{InfoToastEvent, StateChangedEvent};
use crate::game_plugin::GameMutation;
use crate::pause_plugin::PausedResource;
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
use crate::radial_menu::RightClickRadialState;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::ModalScrim;
/// Seconds without any board change before the stall tip fires.
/// Long enough that normal thinking never triggers it; short enough to
/// reach a genuinely stuck player before they quit.
const STALL_TIP_SECS: f32 = 45.0;
/// Move count at which an engaged player earns the radial-menu teach.
const RADIAL_TIP_MIN_MOVES: u32 = 15;
/// Stall-tip copy per platform input vocabulary.
const STALL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
"Stuck? Press H for a hint."
} else {
"Stuck? Tap Hint in the bottom bar for a suggested move."
};
/// Radial-tip copy per platform input vocabulary.
const RADIAL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
"Tip: right-click a card for quick actions."
} else {
"Tip: long-press a card for quick actions."
};
/// Seconds of board inactivity, frozen while paused / a modal is open,
/// reset by every [`StateChangedEvent`].
#[derive(Resource, Debug, Default)]
pub struct StallClock(pub f32);
/// Registers the stall clock and the two tip triggers.
pub struct ContextualTipsPlugin;
impl Plugin for ContextualTipsPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<StallClock>()
.add_message::<InfoToastEvent>()
.add_message::<StateChangedEvent>()
.add_systems(
Update,
(
tick_stall_clock,
fire_stall_hint_tip
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
observe_radial_menu_use,
fire_radial_menu_tip
.in_set(crate::game_plugin::InfoToastWriters)
.ambiguous_with(crate::game_plugin::InfoToastWriters),
)
.chain()
.after(GameMutation),
);
}
}
/// Advances the stall clock; resets on any board change; freezes while
/// paused, while a modal owns the screen, or when the game is over.
fn tick_stall_clock(
time: Res<Time>,
mut state_events: MessageReader<StateChangedEvent>,
paused: Option<Res<PausedResource>>,
scrims: Query<(), With<ModalScrim>>,
game: Option<Res<GameStateResource>>,
mut clock: ResMut<StallClock>,
) {
if state_events.read().next().is_some() {
clock.0 = 0.0;
return;
}
if paused.is_some_and(|p| p.0)
|| !scrims.is_empty()
|| game.as_ref().is_none_or(|g| g.0.is_won())
{
// Frozen, not reset: backgrounding into a menu mid-stall
// shouldn't restart the wait.
return;
}
clock.0 += time.delta_secs();
}
/// Fires the one-shot stall tip once the clock passes the threshold in
/// a game the player has actually started (at least one move).
fn fire_stall_hint_tip(
clock: Res<StallClock>,
game: Option<Res<GameStateResource>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(s) = settings.as_mut() else { return };
if s.0.shown_stall_hint_tip || clock.0 < STALL_TIP_SECS {
return;
}
if game
.as_ref()
.is_none_or(|g| g.0.move_count() == 0 || g.0.is_won())
{
return;
}
toast.write(InfoToastEvent(STALL_TIP.to_string()));
s.0.shown_stall_hint_tip = true;
persist(storage_path.as_deref(), &s.0);
}
/// A player who opens the radial menu on their own doesn't need the
/// teach — mark the tip done silently.
fn observe_radial_menu_use(
radial: Option<Res<RightClickRadialState>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
) {
let Some(radial) = radial else { return };
if !radial.is_active() {
return;
}
let Some(s) = settings.as_mut() else { return };
if s.0.shown_radial_menu_tip {
return;
}
s.0.shown_radial_menu_tip = true;
persist(storage_path.as_deref(), &s.0);
}
/// Fires the one-shot radial teach for an engaged player who has never
/// opened the menu themselves.
fn fire_radial_menu_tip(
game: Option<Res<GameStateResource>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
mut toast: MessageWriter<InfoToastEvent>,
) {
let Some(s) = settings.as_mut() else { return };
if s.0.shown_radial_menu_tip {
return;
}
if game
.as_ref()
.is_none_or(|g| g.0.move_count() < RADIAL_TIP_MIN_MOVES || g.0.is_won())
{
return;
}
toast.write(InfoToastEvent(RADIAL_TIP.to_string()));
s.0.shown_radial_menu_tip = true;
persist(storage_path.as_deref(), &s.0);
}
fn persist(storage_path: Option<&SettingsStoragePath>, settings: &solitaire_data::Settings) {
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, settings)
{
warn!("contextual tips: failed to persist tip flag: {e}");
}
}
#[cfg(test)]
mod tests {
use super::*;
use bevy::ecs::message::Messages;
use solitaire_core::{DrawStockConfig, game_state::GameState};
use solitaire_data::Settings;
fn app_with(settings: Settings, game: GameState) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(ContextualTipsPlugin);
app.insert_resource(SettingsResource(settings));
app.insert_resource(GameStateResource(game));
app.update();
app
}
fn info_toast_count(app: &App) -> usize {
let events = app.world().resource::<Messages<InfoToastEvent>>();
let mut cursor = events.get_cursor();
cursor.read(events).count()
}
fn started_game() -> GameState {
let mut game = GameState::new(7, DrawStockConfig::DrawOne);
game.draw().expect("draw from fresh deal");
game
}
#[test]
fn stall_tip_fires_once_past_threshold() {
let mut app = app_with(Settings::default(), started_game());
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 1, "stall tip must fire");
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_stall_hint_tip,
"the tip flag must set so it never repeats"
);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 30.0;
app.world_mut()
.resource_mut::<Messages<InfoToastEvent>>()
.clear();
app.update();
assert_eq!(info_toast_count(&app), 0, "the tip is one-shot");
}
#[test]
fn stall_tip_needs_a_started_unwon_game() {
// Untouched deal: staring at the launch screen is not a stall.
let mut app = app_with(
Settings::default(),
GameState::new(7, DrawStockConfig::DrawOne),
);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 0, "no tip on an untouched deal");
// Won game: nothing to hint at.
let mut won = started_game();
won.set_test_won(true);
let mut app = app_with(Settings::default(), won);
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
app.update();
assert_eq!(info_toast_count(&app), 0, "no tip on a won game");
}
#[test]
fn stall_clock_resets_on_state_change() {
let mut app = app_with(Settings::default(), started_game());
app.world_mut().resource_mut::<StallClock>().0 = 30.0;
app.world_mut().write_message(StateChangedEvent);
app.update();
assert_eq!(
app.world().resource::<StallClock>().0,
0.0,
"any board change must reset the stall clock"
);
}
#[test]
fn radial_tip_fires_for_engaged_player() {
let mut game = started_game();
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
let mut app = app_with(Settings::default(), game);
app.update();
assert_eq!(
info_toast_count(&app),
1,
"radial tip must fire at the move threshold"
);
assert!(
app.world()
.resource::<SettingsResource>()
.0
.shown_radial_menu_tip
);
}
#[test]
fn radial_tip_suppressed_after_organic_use() {
let mut game = started_game();
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
let mut app = app_with(
Settings {
shown_radial_menu_tip: true,
..Settings::default()
},
game,
);
app.update();
assert_eq!(
info_toast_count(&app),
0,
"a player who used the radial menu never sees the teach"
);
}
}
+8 -11
View File
@@ -14,14 +14,14 @@ use crate::platform::{
}; };
use crate::{ use crate::{
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin, AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin, CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin, StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin, WinSummaryPlugin,
}; };
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
use crate::{ use crate::{
@@ -115,9 +115,6 @@ impl Plugin for CoreGamePlugin {
.add_plugins(PausePlugin) .add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default()) .add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin) .add_plugins(OnboardingPlugin)
.add_plugins(WhatsNewPlugin)
.add_plugins(ContextualTipsPlugin)
.add_plugins(CheatSheetPlugin)
.add_plugins(WinSummaryPlugin) .add_plugins(WinSummaryPlugin)
.add_plugins(UiModalPlugin) .add_plugins(UiModalPlugin)
.add_plugins(UiFocusPlugin) .add_plugins(UiFocusPlugin)
+3 -304
View File
@@ -49,11 +49,10 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
use crate::animation_plugin::CardAnim; use crate::animation_plugin::CardAnim;
use crate::card_animation::{MotionCurve, sample_curve}; use crate::card_plugin::CardEntity;
use crate::card_plugin::{CardEntity, CardEntityIndex};
use crate::events::{ use crate::events::{
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent, DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent, NewGameRequestEvent,
}; };
use crate::game_plugin::GameMutation; use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource; use crate::layout::LayoutResource;
@@ -208,8 +207,6 @@ impl Plugin for FeedbackAnimPlugin {
.add_message::<MoveRejectedEvent>() .add_message::<MoveRejectedEvent>()
.add_message::<NewGameRequestEvent>() .add_message::<NewGameRequestEvent>()
.add_message::<FoundationCompletedEvent>() .add_message::<FoundationCompletedEvent>()
.add_message::<HintVisualEvent>()
.add_message::<StateChangedEvent>()
.add_message::<RequestRedraw>() .add_message::<RequestRedraw>()
.add_systems( .add_systems(
Update, Update,
@@ -227,20 +224,6 @@ impl Plugin for FeedbackAnimPlugin {
start_deal_anim.after(GameMutation), start_deal_anim.after(GameMutation),
start_foundation_flourish.after(GameMutation), start_foundation_flourish.after(GameMutation),
), ),
)
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
// so it orders after the board painters; the tick only touches
// ghost entities (Without<CardEntity>) and stays conflict-free.
.add_systems(
Update,
(
spawn_hint_ghost
.after(GameMutation)
.after(crate::card_plugin::BoardVisuals),
tick_hint_ghosts,
despawn_hint_ghosts_on_state_change.after(GameMutation),
)
.chain(),
); );
} }
} }
@@ -681,147 +664,6 @@ fn pile_cards(
} }
} }
// ---------------------------------------------------------------------------
// Phase H — hint ghost-motion preview
// ---------------------------------------------------------------------------
/// Duration of one ghost glide from the hinted card to its destination.
const HINT_GHOST_PASS_SECS: f32 = 0.7;
/// How many glides one hint plays before the ghost despawns. Two reads
/// as "this move, over there" without outstaying the 2 s static
/// highlight it accompanies.
const HINT_GHOST_PASSES: f32 = 2.0;
/// Ghost translucency — clearly a projection, never mistakable for the
/// real card.
const HINT_GHOST_ALPHA: f32 = 0.45;
/// Ghost render depth: above every settled pile (~1.04 max) and the
/// in-flight `CardAnim` lift (50), below a dragged card (500).
const HINT_GHOST_Z: f32 = 400.0;
/// A translucent copy of the hinted card gliding to the suggested
/// destination (Phase H). Purely decorative — despawned by timer, by a
/// newer hint, or by any state change.
#[derive(Component, Debug)]
pub struct HintGhost {
start: Vec3,
target: Vec3,
elapsed: f32,
}
/// Normalised progress of the current glide pass, restarting from the
/// source each pass. Pure for unit testing.
fn hint_ghost_pass_t(elapsed: f32) -> f32 {
(elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS
}
/// Ghost alpha at `pass_t` — full strength for most of the glide, then
/// fading over the last 20 % so the loop restart reads as a repeat
/// rather than a teleport. Pure for unit testing.
fn hint_ghost_alpha(pass_t: f32) -> f32 {
let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0);
HINT_GHOST_ALPHA * (1.0 - fade_in_tail)
}
/// Spawns the ghost when a hint fires. The static highlights (source
/// card + gold destination pile) still spawn regardless; under
/// reduce-motion they are the whole story and no ghost appears
/// (`design-system.md` §Accessibility).
#[allow(clippy::too_many_arguments)]
fn spawn_hint_ghost(
mut events: MessageReader<HintVisualEvent>,
settings: Option<Res<SettingsResource>>,
index: Option<Res<CardEntityIndex>>,
layout: Option<Res<LayoutResource>>,
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
existing: Query<Entity, With<HintGhost>>,
mut commands: Commands,
) {
if events.is_empty() {
return;
}
if settings.is_some_and(|s| s.0.reduce_motion_mode) {
events.clear();
return;
}
let (Some(index), Some(layout)) = (index, layout) else {
events.clear();
return;
};
for ev in events.read() {
// A fresh hint replaces any ghost still in flight.
for entity in &existing {
commands.entity(entity).despawn();
}
let Some(card_entity) = index.get(&ev.source_card) else {
continue;
};
let Ok((transform, sprite)) = cards.get(card_entity) else {
continue;
};
let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else {
continue;
};
let start = transform.translation.truncate().extend(HINT_GHOST_Z);
let mut ghost_sprite = sprite.clone();
ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA);
commands.spawn((
HintGhost {
start,
target: dest.extend(HINT_GHOST_Z),
elapsed: 0.0,
},
ghost_sprite,
Transform::from_translation(start),
));
}
}
/// Advances every ghost: eased glide per pass, tail fade, despawn after
/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other
/// decorative animation.
#[allow(clippy::type_complexity)]
fn tick_hint_ghosts(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut ghosts: Query<
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
(Without<CardEntity>, Without<PileMarker>),
>,
mut commands: Commands,
) {
if paused.is_some_and(|p| p.0) {
return;
}
let dt = time.delta_secs();
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
ghost.elapsed += dt;
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
commands.entity(entity).despawn();
continue;
}
let pass_t = hint_ghost_pass_t(ghost.elapsed);
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
transform.translation = ghost.start.lerp(ghost.target, eased);
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
}
}
/// A ghost previews a move against the *current* board; the moment the
/// board changes (move, undo, draw, new game) it is stale and vanishes.
fn despawn_hint_ghosts_on_state_change(
mut events: MessageReader<StateChangedEvent>,
ghosts: Query<Entity, With<HintGhost>>,
mut commands: Commands,
) {
if events.is_empty() {
return;
}
events.clear();
for entity in &ghosts {
commands.entity(entity).despawn();
}
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Unit tests (pure functions only — no Bevy world required) // Unit tests (pure functions only — no Bevy world required)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1113,147 +955,4 @@ mod tests {
"FoundationFlourish must not be inserted under reduce-motion" "FoundationFlourish must not be inserted under reduce-motion"
); );
} }
// -----------------------------------------------------------------------
// Phase H — hint ghost-motion preview
// -----------------------------------------------------------------------
#[test]
fn hint_ghost_pass_t_restarts_each_pass() {
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
// Just past one full pass, progress wraps back near zero.
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
}
#[test]
fn hint_ghost_alpha_fades_only_in_the_tail() {
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
}
/// App with one indexed card entity and a real layout, ready to
/// receive `HintVisualEvent`s.
fn ghost_app(reduce_motion: bool) -> (App, Card) {
use solitaire_core::{Deck, Rank, Suit};
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(FeedbackAnimPlugin);
// Sibling systems in the plugin take GameStateResource
// non-optionally; give them a fresh deal.
app.insert_resource(GameStateResource(
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
));
app.insert_resource(SettingsResource(solitaire_data::Settings {
reduce_motion_mode: reduce_motion,
..Default::default()
}));
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
let entity = app
.world_mut()
.spawn((
CardEntity { card: card.clone() },
Sprite::default(),
Transform::from_xyz(-100.0, 40.0, 1.0),
))
.id();
let mut index = CardEntityIndex::default();
index.0.insert(card.clone(), entity);
app.insert_resource(index);
app.insert_resource(LayoutResource(crate::layout::compute_layout(
Vec2::new(1280.0, 720.0),
0.0,
0.0,
true,
)));
app.update();
(app, card)
}
fn fire_hint(app: &mut App, card: Card) {
use solitaire_core::Tableau;
app.world_mut().write_message(HintVisualEvent {
source_card: card,
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
});
app.update();
}
fn ghost_count(app: &mut App) -> usize {
app.world_mut()
.query::<&HintGhost>()
.iter(app.world())
.count()
}
#[test]
fn hint_event_spawns_one_ghost_at_the_source_card() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card);
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
let transform = app
.world_mut()
.query_filtered::<&Transform, With<HintGhost>>()
.single(app.world())
.expect("ghost transform");
// The tick system may have advanced the ghost by one real-time
// frame already; assert it is still essentially at the source.
assert!(
transform
.translation
.truncate()
.distance(Vec2::new(-100.0, 40.0))
< 5.0,
"ghost must start at the hinted card, got {:?}",
transform.translation
);
assert_eq!(transform.translation.z, HINT_GHOST_Z);
}
#[test]
fn hint_ghost_suppressed_under_reduce_motion() {
let (mut app, card) = ghost_app(true);
fire_hint(&mut app, card);
assert_eq!(
ghost_count(&mut app),
0,
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
);
}
#[test]
fn state_change_despawns_the_ghost() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card);
assert_eq!(ghost_count(&mut app), 1);
app.world_mut().write_message(StateChangedEvent);
app.update();
app.update();
assert_eq!(
ghost_count(&mut app),
0,
"a board change makes the previewed move stale — the ghost must vanish"
);
}
#[test]
fn second_hint_replaces_the_first_ghost() {
let (mut app, card) = ghost_app(false);
fire_hint(&mut app, card.clone());
fire_hint(&mut app, card);
app.update();
assert_eq!(
ghost_count(&mut app),
1,
"re-hinting must replace the in-flight ghost, not stack a second"
);
}
} }
-8
View File
@@ -86,14 +86,6 @@ pub struct NewGameRequestWriters;
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct UndoRequestWriters; pub struct UndoRequestWriters;
/// Self-ambiguous set for writers of `DrawRequestEvent` — same rationale as
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
/// meaningless (#143). Members: keyboard `D`/`Space`, stock click, touch
/// stock tap (input_plugin), and the touch action bar's Draw button
/// (hud_plugin, Phase F).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
pub struct DrawRequestWriters;
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in /// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
/// arrival order and any same-frame order is fine (#143). /// arrival order and any same-frame order is fine (#143).
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)] #[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
File diff suppressed because it is too large Load Diff
-169
View File
@@ -1,169 +0,0 @@
//! Single source of truth for the desktop keyboard bindings (Phase J).
//!
//! Two static hotkey tables had already diverged (onboarding's slide
//! and Help's controls reference); every future drift multiplies. This
//! module owns THE table: the onboarding slide renders the
//! [`HotkeyRow::essential`] subset, the hold-`/` cheat sheet
//! ([`crate::cheat_sheet_plugin`]) renders everything.
//!
//! The table is hand-maintained but **pinned by test** against the
//! handlers that actually consume each key — adding a binding without
//! updating this table (or vice versa) is designed to fail review, not
//! runtime. A registry generated from the input systems themselves is
//! the eventual ideal; this is the honest 90 % at 1 % of the cost.
/// One row of the hotkey table.
#[derive(Debug, Clone, Copy)]
pub struct HotkeyRow {
/// Display form of the key(s), e.g. `"D / Space"`.
pub keys: &'static str,
/// One-line action description.
pub description: &'static str,
/// `true` for the beginner-relevant subset the onboarding slide
/// shows; the cheat sheet always shows every row.
pub essential: bool,
}
/// Every desktop keyboard binding, in teaching order.
pub const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
essential: true,
},
HotkeyRow {
keys: "U",
description: "Undo last move (hold to repeat)",
essential: true,
},
HotkeyRow {
keys: "H",
description: "Hint (repeat to cycle alternatives)",
essential: true,
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
essential: true,
},
HotkeyRow {
keys: "N",
description: "New Classic game",
essential: true,
},
HotkeyRow {
keys: "M",
description: "Open Home (then 16 to pick a mode)",
essential: true,
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume; close the top dialog",
essential: true,
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
essential: true,
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
essential: true,
},
HotkeyRow {
keys: "A",
description: "Achievements",
essential: true,
},
HotkeyRow {
keys: "O",
description: "Settings",
essential: true,
},
HotkeyRow {
keys: "P",
description: "Profile",
essential: false,
},
HotkeyRow {
keys: "L",
description: "Leaderboard",
essential: false,
},
HotkeyRow {
keys: "C",
description: "Daily Challenge",
essential: false,
},
HotkeyRow {
keys: "Z",
description: "Zen mode",
essential: false,
},
HotkeyRow {
keys: "X",
description: "Challenge mode",
essential: false,
},
HotkeyRow {
keys: "T",
description: "Time Attack",
essential: false,
},
HotkeyRow {
keys: "G",
description: "Give up the current deal",
essential: false,
},
HotkeyRow {
keys: "[ / ]",
description: "Volume down / up",
essential: false,
},
HotkeyRow {
keys: "F11",
description: "Toggle fullscreen",
essential: false,
},
HotkeyRow {
keys: "/ (hold)",
description: "This cheat sheet",
essential: false,
},
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn table_is_populated_and_well_formed() {
assert!(!HOTKEYS.is_empty());
for row in HOTKEYS {
assert!(!row.keys.trim().is_empty(), "empty keys cell");
assert!(
!row.description.trim().is_empty(),
"empty description for {}",
row.keys
);
}
}
#[test]
fn essential_subset_is_a_teachable_size() {
let essentials = HOTKEYS.iter().filter(|r| r.essential).count();
assert!(
(6..=12).contains(&essentials),
"the onboarding slide wants a skimmable essential set, got {essentials}"
);
}
#[test]
fn keys_cells_are_unique() {
let mut seen = std::collections::HashSet::new();
for row in HOTKEYS {
assert!(seen.insert(row.keys), "duplicate hotkey row: {}", row.keys);
}
}
}
+2 -9
View File
@@ -109,19 +109,12 @@ pub(super) fn apply_action_fade(
/// Visual feedback for every action button — paints idle / hover / pressed /// Visual feedback for every action button — paints idle / hover / pressed
/// states by mutating `BackgroundColor` whenever the interaction state /// states by mutating `BackgroundColor` whenever the interaction state
/// changes. One query covers all action buttons via the shared /// changes. One query covers all action buttons via the shared
/// `ActionButton` marker. Glass tab-bar buttons are excluded: they carry /// `ActionButton` marker.
/// their own per-button colour triple and are painted by
/// `paint_glass_tab_buttons` instead (this system's opaque `ACTION_BTN_*`
/// fills would destroy the translucent glass look).
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
pub(super) fn paint_action_buttons( pub(super) fn paint_action_buttons(
mut buttons: Query< mut buttons: Query<
(&Interaction, &mut BackgroundColor), (&Interaction, &mut BackgroundColor),
( (With<ActionButton>, Changed<Interaction>),
With<ActionButton>,
Without<GlassTabButton>,
Changed<Interaction>,
),
>, >,
) { ) {
for (interaction, mut bg) in &mut buttons { for (interaction, mut bg) in &mut buttons {
@@ -30,86 +30,6 @@ pub(super) fn handle_undo_button(
} }
} }
/// Seconds the Undo button must be continuously held before hold-to-repeat
/// kicks in. Long enough that a normal tap (press + release inside one or
/// two frames) never triggers a second undo.
const UNDO_HOLD_INITIAL_DELAY_SECS: f32 = 0.45;
/// Interval between repeated undos while the hold continues. ~5.5 undos/s —
/// fast enough to unwind a long line, slow enough to release in time when
/// the board reaches the state the player wants.
const UNDO_HOLD_REPEAT_INTERVAL_SECS: f32 = 0.18;
/// Hold-to-repeat undo (Phase F): while the Undo button stays pressed,
/// fire additional [`UndoRequestEvent`]s after an initial delay, one per
/// repeat interval. The plain tap path stays in [`handle_undo_button`]
/// (its `Changed<Interaction>` filter fires exactly once per press);
/// this system only adds events once the hold outlives the delay, so a
/// tap never double-undoes.
///
/// Each repeat goes through the normal request queue — the scoring
/// penalty and No-Undo-mode gating in the consumer apply to every step.
pub(super) fn repeat_undo_on_hold(
time: Res<Time>,
buttons: Query<&Interaction, With<UndoButton>>,
mut state: ResMut<UndoHoldState>,
mut undo: MessageWriter<UndoRequestEvent>,
) {
let held = buttons.iter().any(|i| *i == Interaction::Pressed);
if !held {
if state.held_secs != 0.0 {
state.held_secs = 0.0;
}
return;
}
let before = state.held_secs;
state.held_secs += time.delta_secs();
if undo_hold_crossed_fire_boundary(before, state.held_secs) {
undo.write(UndoRequestEvent);
}
}
/// `true` when a hold that lasted `before` seconds at the previous frame
/// and `now` seconds this frame should fire a repeat undo: once when the
/// hold first outlives the initial delay, then once per repeat-interval
/// boundary. Pure so the timing contract is unit-testable without
/// fighting `Time`.
pub(super) fn undo_hold_crossed_fire_boundary(before: f32, now: f32) -> bool {
if now < UNDO_HOLD_INITIAL_DELAY_SECS {
return false;
}
if before < UNDO_HOLD_INITIAL_DELAY_SECS {
// Crossed the initial-delay threshold this frame — first repeat.
return true;
}
// Fire once each time the hold crosses another repeat-interval boundary.
let intervals_before =
((before - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
let intervals_now =
((now - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
intervals_now > intervals_before
}
/// Click on the touch action bar's Draw button — same
/// [`DrawRequestEvent`] the stock-pile tap writes, so the consumer's
/// rules (recycle, won-game rejection) apply identically. Skipped while
/// paused, mirroring `handle_stock_click`'s guard.
pub(super) fn handle_draw_button(
interaction_query: Query<&Interaction, (With<DrawButton>, Changed<Interaction>)>,
paused: Option<Res<PausedResource>>,
mut draw: MessageWriter<DrawRequestEvent>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
for interaction in &interaction_query {
if *interaction == Interaction::Pressed {
draw.write(DrawRequestEvent);
}
}
}
pub(super) fn handle_pause_button( pub(super) fn handle_pause_button(
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>, interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
mut pause: MessageWriter<PauseRequestEvent>, mut pause: MessageWriter<PauseRequestEvent>,
+19 -45
View File
@@ -12,13 +12,11 @@ use bevy::window::WindowResized;
mod fx; mod fx;
mod interaction; mod interaction;
mod spawn; mod spawn;
pub(crate) mod tab_bar;
mod updates; mod updates;
pub use fx::*; pub use fx::*;
use interaction::*; use interaction::*;
use spawn::*; use spawn::*;
use tab_bar::*;
use updates::*; use updates::*;
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the // On wasm32 AvatarPlugin is gated out; define a placeholder type so the
@@ -30,7 +28,7 @@ struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL; use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
use crate::daily_challenge_plugin::DailyChallengeResource; use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{ use crate::events::{
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent, StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent, StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
@@ -315,18 +313,6 @@ pub struct UndoButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct PauseButton; pub struct PauseButton;
/// Accumulated hold time on the Undo button, driving hold-to-repeat undo
/// (Phase F). `handle_undo_button` owns the instant first undo on press;
/// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s
/// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every
/// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the
/// normal request path, so the existing scoring penalty applies per step.
#[derive(Resource, Debug, Default)]
pub struct UndoHoldState {
/// Seconds the Undo button has been continuously held.
held_secs: f32,
}
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`], /// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
/// mirroring the `F1` keyboard accelerator. /// mirroring the `F1` keyboard accelerator.
#[derive(Component, Debug)] #[derive(Component, Debug)]
@@ -337,27 +323,31 @@ pub struct HelpButton;
#[derive(Component, Debug)] #[derive(Component, Debug)]
pub struct HintButton; pub struct HintButton;
/// Marker on the touch action bar's "Draw" button (Phase F). Click fires
/// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the
/// draw action is within thumb reach without stretching to the top of a
/// tall folded screen. Touch layout only; desktop draws via stock
/// click / `D` / `Space`.
#[derive(Component, Debug)]
pub struct DrawButton;
/// Android HUD label for the Hint button — shared with the help screen's /// Android HUD label for the Hint button — shared with the help screen's
/// controls reference so both always agree. /// controls reference so both always agree.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint"; pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
/// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`]
/// on Android so the bar and the help screen's controls reference can
/// never drift; the non-Android value only exists so the touch spawn
/// path stays host-compilable for tests.
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL; const ACTION_BAR_LABELS: [&str; 7] = [
"Menu",
"Undo",
"Pause",
"Help",
ANDROID_HINT_LABEL,
"Mode",
"New",
];
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
const TOUCH_HINT_LABEL: &str = "Hint"; const ACTION_BAR_LABELS: [&str; 7] = [
"Menu \u{2193}",
"Undo",
"Pause",
"Help",
"Hint",
"Modes \u{2193}",
"New Game",
];
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0); const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
#[cfg(not(target_os = "android"))] #[cfg(not(target_os = "android"))]
@@ -465,7 +455,6 @@ impl Plugin for HudPlugin {
// idempotent. // idempotent.
app.add_message::<NewGameRequestEvent>() app.add_message::<NewGameRequestEvent>()
.add_message::<UndoRequestEvent>() .add_message::<UndoRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<PauseRequestEvent>() .add_message::<PauseRequestEvent>()
.add_message::<HelpRequestEvent>() .add_message::<HelpRequestEvent>()
.add_message::<StartZenRequestEvent>() .add_message::<StartZenRequestEvent>()
@@ -482,7 +471,6 @@ impl Plugin for HudPlugin {
.init_resource::<PreviousScore>() .init_resource::<PreviousScore>()
.init_resource::<HudActionFade>() .init_resource::<HudActionFade>()
.init_resource::<HudVisibility>() .init_resource::<HudVisibility>()
.init_resource::<UndoHoldState>()
// Escape-close handlers for popovers read this; init defensively // Escape-close handlers for popovers read this; init defensively
// so HudPlugin works under MinimalPlugins in tests. // so HudPlugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>() .init_resource::<ButtonInput<KeyCode>>()
@@ -572,14 +560,6 @@ impl Plugin for HudPlugin {
.in_set(crate::game_plugin::UndoRequestWriters) .in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters) .ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation), .before(GameMutation),
repeat_undo_on_hold
.in_set(crate::game_plugin::UndoRequestWriters)
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
.before(GameMutation),
handle_draw_button
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
.before(GameMutation),
handle_pause_button, handle_pause_button,
handle_help_button, handle_help_button,
handle_hint_button handle_hint_button
@@ -597,12 +577,6 @@ impl Plugin for HudPlugin {
handle_menu_backdrop_click, handle_menu_backdrop_click,
close_menu_popover_on_escape, close_menu_popover_on_escape,
paint_action_buttons, paint_action_buttons,
paint_glass_tab_buttons,
drive_tab_expansion,
// Writes tab-label TextColor and reads TextFont — entity-
// disjoint from the UiTextFx readout writers (tab labels
// vs HUD readouts), which Bevy can't prove, so declare it.
animate_tab_expansion.ambiguous_with(UiTextFx),
) )
.chain() .chain()
.in_set(HudButtons) .in_set(HudButtons)
+20 -57
View File
@@ -360,7 +360,6 @@ pub(super) fn handle_avatar_button(
pub(super) fn spawn_action_buttons( pub(super) fn spawn_action_buttons(
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
windows: Query<&Window>, windows: Query<&Window>,
images: Option<ResMut<Assets<Image>>>,
mut commands: Commands, mut commands: Commands,
) { ) {
let action_font_size = let action_font_size =
@@ -371,18 +370,9 @@ pub(super) fn spawn_action_buttons(
..default() ..default()
}; };
// Touch layout: the floating glass tab bar (pill + circular Menu). // Bottom bar: full-width, centered, sits above the gesture-navigation zone.
// `Assets<Image>` is absent under `MinimalPlugins`; `rasterize_into`
// returning `None` switches the bar to its text-fallback form.
if USE_TOUCH_UI_LAYOUT {
let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs));
spawn_glass_tab_bar(&mut commands, &font, icons.as_ref());
return;
}
// Desktop bottom bar: full-width, centered, docked to the bottom edge.
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once // `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
// the platform reports it; initial value is 0.0. // Android reports it (frames 1-3); initial value is 0.0.
commands commands
.spawn(( .spawn((
Node { Node {
@@ -410,13 +400,6 @@ pub(super) fn spawn_action_buttons(
HudActionBar, HudActionBar,
)) ))
.with_children(|row| { .with_children(|row| {
spawn_desktop_action_bar(row, &font);
});
}
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
/// is touch-only). All seven actions, uniform sizing.
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
// The trailing `order` argument feeds `Focusable { group: Hud, order }` // The trailing `order` argument feeds `Focusable { group: Hud, order }`
// so Tab cycles the action bar in visual reading order. // so Tab cycles the action bar in visual reading order.
// Undo and Pause are the primary gameplay actions — full brightness. // Undo and Pause are the primary gameplay actions — full brightness.
@@ -424,73 +407,74 @@ pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &Te
spawn_action_button( spawn_action_button(
row, row,
MenuButton, MenuButton,
"Menu \u{2193}", ACTION_BAR_LABELS[0],
None, None,
"Open Stats, Achievements, Profile, Settings, or Leaderboard.", "Open Stats, Achievements, Profile, Settings, or Leaderboard.",
font, &font,
0, 0,
TEXT_SECONDARY, TEXT_SECONDARY,
); );
spawn_action_button( spawn_action_button(
row, row,
UndoButton, UndoButton,
"Undo", ACTION_BAR_LABELS[1],
Some("U"), Some("U"),
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.", "Take back your last move. Costs points and blocks No Undo.",
font, &font,
1, 1,
TEXT_PRIMARY, TEXT_PRIMARY,
); );
spawn_action_button( spawn_action_button(
row, row,
PauseButton, PauseButton,
"Pause", ACTION_BAR_LABELS[2],
Some("Esc"), Some("Esc"),
"Pause the game and freeze the timer.", "Pause the game and freeze the timer.",
font, &font,
2, 2,
TEXT_PRIMARY, TEXT_PRIMARY,
); );
spawn_action_button( spawn_action_button(
row, row,
HelpButton, HelpButton,
"Help", ACTION_BAR_LABELS[3],
Some("F1"), Some("F1"),
"Show controls, rules, and keyboard shortcuts.", "Show controls, rules, and keyboard shortcuts.",
font, &font,
3, 3,
TEXT_SECONDARY, TEXT_SECONDARY,
); );
spawn_action_button( spawn_action_button(
row, row,
HintButton, HintButton,
"Hint", ACTION_BAR_LABELS[4],
Some("H"), Some("H"),
"Highlight a suggested move. Cycles through alternatives on repeat taps.", "Highlight a suggested move. Cycles through alternatives on repeat taps.",
font, &font,
4, 4,
TEXT_SECONDARY, TEXT_SECONDARY,
); );
spawn_action_button( spawn_action_button(
row, row,
ModesButton, ModesButton,
"Modes \u{2193}", ACTION_BAR_LABELS[5],
None, None,
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.", "Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
font, &font,
5, 5,
TEXT_SECONDARY, TEXT_SECONDARY,
); );
spawn_action_button( spawn_action_button(
row, row,
NewGameButton, NewGameButton,
"New Game", ACTION_BAR_LABELS[6],
Some("N"), Some("N"),
"Start a fresh deal. Confirms first if a game is in progress.", "Start a fresh deal. Confirms first if a game is in progress.",
font, &font,
6, 6,
TEXT_SECONDARY, TEXT_SECONDARY,
); );
});
} }
/// Spawns a single action button as a child of `row`. Each button shares /// Spawns a single action button as a child of `row`. Each button shares
@@ -526,34 +510,13 @@ pub(super) fn spawn_action_button<M: Component>(
None None
}; };
let (pad, min_w, min_h) = action_button_metrics();
spawn_action_button_sized(
row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h,
);
}
/// Shared body of [`spawn_action_button`] — one place owns the component
/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
/// bar button identically.
#[allow(clippy::too_many_arguments)]
fn spawn_action_button_sized<M: Component>(
row: &mut ChildSpawnerCommands,
marker: M,
label: &str,
hotkey: Option<&'static str>,
tooltip: &'static str,
font: &TextFont,
order: i32,
text_color: Color,
pad: UiRect,
min_w: Val,
min_h: Val,
) {
let hotkey_font = TextFont { let hotkey_font = TextFont {
font: font.font.clone(), font: font.font.clone(),
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}; };
let (pad, min_w, min_h) = action_button_metrics();
row.spawn(( row.spawn((
marker, marker,
ActionButton, ActionButton,
-470
View File
@@ -1,470 +0,0 @@
//! Floating "liquid glass" touch tab bar — layout and animation.
//!
//! Replaces the docked full-width touch action bar (Phase F) with an
//! iOS-26-style floating arrangement: a pill of icon buttons hovering
//! above the bottom edge, plus a detached circular Menu button to its
//! right. The *visual treatment* (translucent sheen, specular rim, drop
//! shadow) lives in [`crate::ui_glass`]; this module owns only geometry,
//! button wiring, and the expand/collapse label animation, so the glass
//! material can be swapped without touching layout.
//!
//! Interaction compatibility: every button keeps the same marker
//! components as the old bar (`UndoButton`, `DrawButton`, …) plus
//! `ActionButton`, `Button`, `Tooltip`, and `Focusable`, so the click
//! handlers, tooltips, focus ring, and the tap-to-toggle chrome guard in
//! `interaction.rs` all work unchanged.
//!
//! Expansion model (user decision, 2026-07-15): **Draw** — the most
//! frequent action — is the persistently-expanded "active" pill showing
//! icon + label; the other buttons are icon-only and reveal their label
//! with a width-animated slide while pressed.
use super::*;
use crate::assets::hud_icon_svg::HudIcon;
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
use bevy::text::{LineBreak, TextLayout};
use bevy::ui::Overflow;
/// Height of both the pill and the circular Menu button, in logical px.
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
/// floor with 8 px of glass around them.
pub(crate) const TAB_BAR_HEIGHT: f32 = 64.0;
/// Gap between the bar and the bottom screen edge (the "floating" margin;
/// the Android gesture-bar inset is added on top by `SafeAreaAnchoredBottom`).
const TAB_BAR_MARGIN: f32 = SPACE_3;
/// Vertical space UI stacked above the bar (the toast stack) must clear:
/// floating margin + bar height + one spacing step of breathing room.
pub(crate) const TAB_BAR_CLEARANCE_PX: f32 = TAB_BAR_MARGIN + TAB_BAR_HEIGHT + SPACE_2;
/// Diameter of a button inside the pill (and its fully-round radius ×2).
const TAB_BUTTON_SIZE: f32 = 48.0;
/// Displayed icon size in logical px (texture is rasterised larger; see
/// `assets::hud_icon_svg::HUD_ICON_TEXTURE_PX`).
const TAB_ICON_PX: f32 = 24.0;
/// FiraMono glyph advance as a fraction of font size. The bar is
/// monospace-only, so `chars × advance × font_size` is the exact label
/// width — the same ratio `action_bar_font_size` documents.
const FIRA_ADVANCE: f32 = 0.62;
/// Gap between an icon and its revealed label, folded into the animated
/// wrapper width so it collapses together with the text.
const LABEL_LEAD_PX: f32 = SPACE_2;
/// Idle fill of an inactive icon button — fully transparent so the pill's
/// glass shows through untouched.
const GLASS_BTN_IDLE: Color = Color::srgba(1.0, 1.0, 1.0, 0.0);
/// Hover fill of an inactive icon button (desktop pointer / DPAD focus).
const GLASS_BTN_HOVER: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
/// Pressed fill of an inactive icon button.
const GLASS_BTN_PRESSED: Color = Color::srgba(1.0, 1.0, 1.0, 0.14);
/// Pressed state of the active (accent-filled) pill — darker than idle so
/// the press reads as the surface receding, mirroring `BG_ELEVATED_PRESSED`.
const GLASS_ACTIVE_PRESSED: Color = Color::srgb(0.55, 0.22, 0.22);
/// Idle / hovered / pressed fills for one tab-bar button. Presence of this
/// component also routes the button to `paint_glass_tab_buttons` instead of
/// the legacy `paint_action_buttons` (which excludes it).
#[derive(Component, Debug)]
pub struct GlassTabButton {
idle: Color,
hovered: Color,
pressed: Color,
}
/// Animated label-reveal state, on the clipping wrapper node between the
/// button and its `Text` label. `progress` ∈ [0, 1] maps to wrapper width
/// via [`expansion_width`]; `target` is where it is heading.
#[derive(Component, Debug)]
pub struct TabLabelWrap {
/// Current reveal amount, 0 = collapsed, 1 = fully expanded.
pub progress: f32,
/// Reveal amount the animation is moving towards.
pub target: f32,
/// `true` keeps the label expanded regardless of interaction —
/// the persistent Draw pill, and every button in text-fallback mode.
persistent: bool,
/// Label length; width is recomputed from the live font size each
/// frame so viewport-driven font rescales stay correct.
label_chars: usize,
/// Last width written to the node, to skip no-op layout writes.
last_width: f32,
}
/// Marker on the label `Text` inside a [`TabLabelWrap`].
#[derive(Component, Debug)]
pub struct TabLabel;
/// Rasterised icon textures for the five tab-bar actions.
pub(super) struct TabIcons {
undo: Handle<Image>,
draw: Handle<Image>,
hint: Handle<Image>,
pause: Handle<Image>,
menu: Handle<Image>,
}
impl TabIcons {
/// Rasterise all five icons into `images`. Returns `None` (and warns)
/// if any icon fails — the caller then falls back to text-only labels,
/// so a bad SVG degrades the look, never the playability.
pub(super) fn rasterize_into(images: &mut Assets<Image>) -> Option<Self> {
let mut add = |icon: HudIcon| match icon.rasterize() {
Ok(img) => Some(images.add(img)),
Err(e) => {
warn!("tab_bar: failed to rasterise {icon:?} icon: {e}");
None
}
};
Some(Self {
undo: add(HudIcon::Undo)?,
draw: add(HudIcon::Draw)?,
hint: add(HudIcon::Hint)?,
pause: add(HudIcon::Pause)?,
menu: add(HudIcon::Menu)?,
})
}
}
/// Wrapper width in logical px for a label of `chars` monospace glyphs at
/// `font_size`, revealed to `progress`. Pure so the animation curve is
/// unit-testable without an app.
pub(super) fn expansion_width(chars: usize, font_size: f32, progress: f32) -> f32 {
progress.clamp(0.0, 1.0) * (LABEL_LEAD_PX + chars as f32 * font_size * FIRA_ADVANCE)
}
/// Advance `progress` towards `target` by one frame of `dt` seconds.
/// Linear tween over `MOTION_SLIDE_SECS`, snapping when reduce-motion is
/// active. Pure for the same reason as [`expansion_width`].
pub(super) fn step_expansion(progress: f32, target: f32, dt: f32, reduce_motion: bool) -> f32 {
if reduce_motion {
return target;
}
let max_step = dt / crate::ui_theme::MOTION_SLIDE_SECS;
progress + (target - progress).clamp(-max_step, max_step)
}
/// Spawns the floating glass tab bar: one full-width invisible flex row
/// (carrying the `HudActionBar` marker so chrome show/hide keeps working)
/// centring the glass pill and the detached circular Menu button.
///
/// `icons: None` (rasteriser unavailable, e.g. `MinimalPlugins` tests, or
/// an SVG regression) switches every button to text-fallback: labels stay
/// expanded so the bar remains fully usable without textures.
pub(super) fn spawn_glass_tab_bar(
commands: &mut Commands,
font: &TextFont,
icons: Option<&TabIcons>,
) {
let text_fallback = icons.is_none();
commands
.spawn((
Node {
position_type: PositionType::Absolute,
bottom: Val::Px(TAB_BAR_MARGIN),
left: Val::Px(0.0),
width: Val::Percent(100.0),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
},
ZIndex(Z_HUD),
SafeAreaAnchoredBottom {
base_bottom: TAB_BAR_MARGIN,
},
HudActionBar,
))
.with_children(|bar| {
// The main pill: Undo · [Draw] · Hint · Pause.
bar.spawn((
Node {
height: Val::Px(TAB_BAR_HEIGHT),
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(SPACE_2), Val::Px(0.0)),
column_gap: Val::Px(4.0),
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
..default()
},
glass_surface(),
))
.with_children(|pill| {
spawn_tab_button(
pill,
UndoButton,
"Undo",
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
font,
0,
icons.map(|i| i.undo.clone()),
false,
text_fallback,
);
spawn_tab_button(
pill,
DrawButton,
"Draw",
"Draw from the stock — same as tapping the deck.",
font,
1,
icons.map(|i| i.draw.clone()),
true,
text_fallback,
);
spawn_tab_button(
pill,
HintButton,
TOUCH_HINT_LABEL,
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
font,
2,
icons.map(|i| i.hint.clone()),
false,
text_fallback,
);
spawn_tab_button(
pill,
PauseButton,
"Pause",
"Pause the game and freeze the timer.",
font,
3,
icons.map(|i| i.pause.clone()),
false,
text_fallback,
);
});
// The detached circular Menu button, same glass material.
bar.spawn((
MenuButton,
ActionButton,
Button,
GlassTabButton {
idle: GLASS_BTN_IDLE,
hovered: GLASS_BTN_HOVER,
pressed: GLASS_BTN_PRESSED,
},
Tooltip::new(
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
),
Focusable {
group: FocusGroup::Hud,
order: 4,
},
Node {
width: Val::Px(TAB_BAR_HEIGHT),
height: Val::Px(TAB_BAR_HEIGHT),
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
..default()
},
BackgroundColor(GLASS_BTN_IDLE),
glass_surface(),
))
.with_children(|circle| {
if let Some(icons) = icons {
spawn_tab_icon(circle, icons.menu.clone(), TEXT_PRIMARY);
} else {
// Text fallback: a menu glyph-free label keeps the
// button discoverable without any texture.
circle.spawn((
Text::new("Menu"),
TextLayout::new_with_linebreak(LineBreak::NoWrap),
font.clone(),
TextColor(TEXT_PRIMARY),
));
}
});
});
}
/// Spawns one button inside the pill: icon, then the width-animated label
/// wrapper. `active` gives the accent-filled persistent pill (Draw);
/// `text_fallback` pins every label open when icons are unavailable.
#[allow(clippy::too_many_arguments)]
fn spawn_tab_button<M: Component>(
pill: &mut ChildSpawnerCommands,
marker: M,
label: &str,
tooltip: &'static str,
font: &TextFont,
order: i32,
icon: Option<Handle<Image>>,
active: bool,
text_fallback: bool,
) {
let (idle, hovered, pressed) = if active {
(ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, GLASS_ACTIVE_PRESSED)
} else {
(GLASS_BTN_IDLE, GLASS_BTN_HOVER, GLASS_BTN_PRESSED)
};
let icon_tint = if active { TEXT_PRIMARY } else { TEXT_SECONDARY };
let persistent = active || text_fallback;
let start = if persistent { 1.0 } else { 0.0 };
let chars = label.chars().count();
pill.spawn((
marker,
ActionButton,
Button,
GlassTabButton {
idle,
hovered,
pressed,
},
Tooltip::new(tooltip),
Focusable {
group: FocusGroup::Hud,
order,
},
Node {
height: Val::Px(TAB_BUTTON_SIZE),
min_width: Val::Px(TAB_BUTTON_SIZE),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
align_items: AlignItems::Center,
padding: UiRect::axes(Val::Px(SPACE_3), Val::Px(0.0)),
border_radius: BorderRadius::all(Val::Px(TAB_BUTTON_SIZE / 2.0)),
..default()
},
BackgroundColor(idle),
))
.with_children(|b| {
if let Some(handle) = icon {
spawn_tab_icon(b, handle, icon_tint);
}
// The clipping wrapper the animation drives. Label text sits
// left-anchored inside, so growing the width reveals it
// left-to-right and shrinking clips it from the right.
b.spawn((
TabLabelWrap {
progress: start,
target: start,
persistent,
label_chars: chars,
last_width: f32::NAN,
},
Node {
overflow: Overflow::clip(),
width: Val::Px(expansion_width(chars, font.font_size, start)),
height: Val::Percent(100.0),
align_items: AlignItems::Center,
justify_content: JustifyContent::FlexEnd,
..default()
},
))
.with_children(|wrap| {
let mut color = TEXT_PRIMARY;
color.set_alpha(start);
wrap.spawn((
TabLabel,
ActionButtonLabel,
Text::new(label),
TextLayout::new_with_linebreak(LineBreak::NoWrap),
font.clone(),
TextColor(color),
));
});
});
}
/// One fixed-size tinted icon node.
fn spawn_tab_icon(parent: &mut ChildSpawnerCommands, handle: Handle<Image>, tint: Color) {
parent.spawn((
ImageNode {
color: tint,
..ImageNode::new(handle)
},
Node {
width: Val::Px(TAB_ICON_PX),
height: Val::Px(TAB_ICON_PX),
flex_shrink: 0.0,
..default()
},
));
}
/// Sets each label wrapper's animation target from its button's
/// interaction state: persistent wrappers pin to 1, the rest expand while
/// pressed or hovered and collapse on release.
pub(super) fn drive_tab_expansion(
buttons: Query<(&Interaction, &Children), With<GlassTabButton>>,
mut wraps: Query<&mut TabLabelWrap>,
) {
for (interaction, children) in &buttons {
for child in children.iter() {
let Ok(mut wrap) = wraps.get_mut(child) else {
continue;
};
let target = if wrap.persistent || !matches!(interaction, Interaction::None) {
1.0
} else {
0.0
};
// Bypass change detection on no-ops so `animate_tab_expansion`
// and layout stay idle while nothing moves.
if wrap.target != target {
wrap.target = target;
}
}
}
}
/// Advances every label reveal towards its target and writes the wrapper
/// width + label alpha. Width is derived from the label's *live* font size,
/// so the Fold's posture-change font rescale re-widens expanded labels
/// automatically. Node writes are skipped when the width is unchanged to
/// avoid dirtying UI layout every frame.
pub(super) fn animate_tab_expansion(
time: Res<Time>,
settings: Option<Res<SettingsResource>>,
mut wraps: Query<(&mut TabLabelWrap, &mut Node, &Children)>,
mut labels: Query<(&TextFont, &mut TextColor), With<TabLabel>>,
) {
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
let dt = time.delta_secs();
for (mut wrap, mut node, children) in &mut wraps {
for child in children.iter() {
let Ok((font, mut text_color)) = labels.get_mut(child) else {
continue;
};
let next = step_expansion(wrap.progress, wrap.target, dt, reduce_motion);
let width = expansion_width(wrap.label_chars, font.font_size, next);
// `!=` (not epsilon) is deliberate: last_width starts NaN so the
// first frame always writes, and equal floats mean a true no-op.
if next != wrap.progress || width != wrap.last_width {
wrap.progress = next;
wrap.last_width = width;
node.width = Val::Px(width);
text_color.0.set_alpha(next);
}
break;
}
}
}
/// Idle / hover / pressed feedback for glass tab buttons — the glass
/// counterpart of `paint_action_buttons`, using each button's own colour
/// triple so the accent-filled active pill and the transparent icon
/// buttons share one system.
pub(super) fn paint_glass_tab_buttons(
mut buttons: Query<(&Interaction, &GlassTabButton, &mut BackgroundColor), Changed<Interaction>>,
) {
for (interaction, style, mut bg) in &mut buttons {
bg.0 = match interaction {
Interaction::Pressed => style.pressed,
Interaction::Hovered => style.hovered,
Interaction::None => style.idle,
};
}
}
+1 -235
View File
@@ -670,7 +670,7 @@ fn hud_elements_carry_expected_tooltip_strings() {
); );
assert_eq!( assert_eq!(
tooltip_for::<UndoButton>(&mut app), tooltip_for::<UndoButton>(&mut app),
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo." "Take back your last move. Costs points and blocks No Undo."
); );
assert_eq!( assert_eq!(
tooltip_for::<PauseButton>(&mut app), tooltip_for::<PauseButton>(&mut app),
@@ -899,237 +899,3 @@ fn hud_focus_only_engages_when_button_hovered() {
"Hud-engaged Tab must focus a Hud-grouped entity" "Hud-engaged Tab must focus a Hud-grouped entity"
); );
} }
// ---------------------------------------------------------------------------
// Phase F: touch action bar + hold-to-repeat undo
// ---------------------------------------------------------------------------
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
/// buttons the tested spawn function creates — `headless_app` would
/// pre-spawn the platform-default bar and pollute the counts.
///
/// The touch branch spawns the glass tab bar with `icons: None`
/// (`MinimalPlugins` has no `Assets<Image>`), which exercises the
/// text-fallback path — same markers, labels pinned expanded.
fn bar_only_app(touch: bool) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.update();
let font = TextFont::default();
let world = app.world_mut();
let mut commands = world.commands();
if touch {
spawn_glass_tab_bar(&mut commands, &font, None);
} else {
commands.spawn(Node::default()).with_children(|row| {
spawn_desktop_action_bar(row, &font);
});
}
app.update();
app
}
fn count_buttons<C: Component>(app: &mut App) -> usize {
app.world_mut()
.query_filtered::<(), With<C>>()
.iter(app.world())
.count()
}
#[test]
fn touch_tab_bar_has_thumb_trio_plus_menu_and_pause() {
let mut app = bar_only_app(true);
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
assert_eq!(count_buttons::<DrawButton>(&mut app), 1);
assert_eq!(count_buttons::<HintButton>(&mut app), 1);
assert_eq!(count_buttons::<MenuButton>(&mut app), 1);
assert_eq!(count_buttons::<PauseButton>(&mut app), 1);
// Utility actions live elsewhere on touch: Help in Menu → System,
// modes on the Home screen, New Game on Home's hero button.
assert_eq!(count_buttons::<HelpButton>(&mut app), 0);
assert_eq!(count_buttons::<ModesButton>(&mut app), 0);
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
}
/// The glass bar floats: its container must carry a bottom margin (not be
/// docked at 0) both before and after safe-area insets arrive.
#[test]
fn glass_tab_bar_floats_above_bottom_edge() {
let mut app = bar_only_app(true);
let world = app.world_mut();
let mut q = world.query_filtered::<(&Node, &SafeAreaAnchoredBottom), With<HudActionBar>>();
let (node, anchor) = q.single(world).expect("glass bar container exists");
assert!(anchor.base_bottom > 0.0, "bar must float, not dock");
assert_eq!(node.bottom, Val::Px(anchor.base_bottom));
}
/// Draw is the persistently-expanded active pill (user decision
/// 2026-07-15); with `icons: None` every wrapper is pinned expanded via
/// text fallback, so instead assert on the *persistent* flag's effect:
/// all wrappers spawn at full reveal and target full reveal.
#[test]
fn glass_tab_bar_text_fallback_pins_labels_expanded() {
let mut app = bar_only_app(true);
let world = app.world_mut();
let mut q = world.query::<&TabLabelWrap>();
let wraps: Vec<_> = q.iter(world).collect();
assert_eq!(wraps.len(), 4, "Undo/Draw/Hint/Pause each carry a wrapper");
for wrap in wraps {
assert_eq!(wrap.progress, 1.0);
assert_eq!(wrap.target, 1.0);
}
}
/// Focus order must match visual reading order: pill buttons left to
/// right, then the detached Menu circle.
#[test]
fn glass_tab_bar_focus_order_reads_left_to_right() {
let mut app = bar_only_app(true);
for (f, expected) in [
(focusable_for::<UndoButton>(&mut app), 0),
(focusable_for::<DrawButton>(&mut app), 1),
(focusable_for::<HintButton>(&mut app), 2),
(focusable_for::<PauseButton>(&mut app), 3),
(focusable_for::<MenuButton>(&mut app), 4),
] {
assert_eq!(f.group, FocusGroup::Hud);
assert_eq!(f.order, expected);
}
}
/// `expansion_width` is the single source of truth for the label reveal:
/// zero when collapsed, lead + monospace advance when expanded, linear
/// in between.
#[test]
fn expansion_width_is_linear_in_progress() {
assert_eq!(expansion_width(4, 16.0, 0.0), 0.0);
let full = expansion_width(4, 16.0, 1.0);
assert!(full > 4.0 * 16.0 * 0.62, "full width includes the lead gap");
let half = expansion_width(4, 16.0, 0.5);
assert!((half - full / 2.0).abs() < f32::EPSILON * 100.0);
// Out-of-range progress clamps rather than extrapolating.
assert_eq!(expansion_width(4, 16.0, 1.5), full);
}
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
/// and snap instantly under reduce-motion.
#[test]
fn step_expansion_tweens_and_respects_reduce_motion() {
// One 60 fps frame covers 1/(0.18*60) ≈ 9.3% of the reveal.
let one_frame = step_expansion(0.0, 1.0, 1.0 / 60.0, false);
assert!(one_frame > 0.0 && one_frame < 0.2);
// A huge dt clamps at the target instead of overshooting.
assert_eq!(step_expansion(0.0, 1.0, 10.0, false), 1.0);
// Collapse works symmetrically.
assert!(step_expansion(1.0, 0.0, 1.0 / 60.0, false) < 1.0);
// Reduce-motion snaps regardless of dt.
assert_eq!(step_expansion(0.0, 1.0, 0.0, true), 1.0);
}
#[test]
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
let mut app = bar_only_app(false);
for (count, name) in [
(count_buttons::<MenuButton>(&mut app), "Menu"),
(count_buttons::<UndoButton>(&mut app), "Undo"),
(count_buttons::<PauseButton>(&mut app), "Pause"),
(count_buttons::<HelpButton>(&mut app), "Help"),
(count_buttons::<HintButton>(&mut app), "Hint"),
(count_buttons::<ModesButton>(&mut app), "Modes"),
(count_buttons::<NewGameButton>(&mut app), "New Game"),
] {
assert_eq!(count, 1, "desktop bar must keep its {name} button");
}
assert_eq!(
count_buttons::<DrawButton>(&mut app),
0,
"Draw is touch-only (decision 5); desktop draws via stock click / D / Space"
);
}
#[test]
fn draw_button_press_fires_draw_request() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<DrawRequestEvent>>()
.clear();
app.world_mut().spawn((DrawButton, Interaction::Pressed));
app.update();
let events = app.world().resource::<Messages<DrawRequestEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
1,
"pressing the Draw button must fire exactly one DrawRequestEvent"
);
}
#[test]
fn draw_button_press_is_noop_while_paused() {
let mut app = headless_app();
app.insert_resource(PausedResource(true));
app.world_mut()
.resource_mut::<Messages<DrawRequestEvent>>()
.clear();
app.world_mut().spawn((DrawButton, Interaction::Pressed));
app.update();
let events = app.world().resource::<Messages<DrawRequestEvent>>();
let mut cursor = events.get_cursor();
assert_eq!(
cursor.read(events).count(),
0,
"the Draw button must be inert while paused (mirrors the stock-click guard)"
);
}
/// The timing contract for hold-to-repeat undo, exercised through the
/// pure boundary function so no wall-clock is involved. Delay = 0.45 s,
/// interval = 0.18 s → boundaries at 0.45, 0.63, 0.81, …
#[test]
fn undo_hold_boundary_math() {
// A tap never repeats.
assert!(!undo_hold_crossed_fire_boundary(0.0, 0.05));
assert!(!undo_hold_crossed_fire_boundary(0.2, 0.44));
// Crossing the initial delay fires the first repeat.
assert!(undo_hold_crossed_fire_boundary(0.44, 0.46));
// Inside the first repeat interval: quiet.
assert!(!undo_hold_crossed_fire_boundary(0.46, 0.62));
// Each interval boundary fires exactly once.
assert!(undo_hold_crossed_fire_boundary(0.62, 0.64));
assert!(!undo_hold_crossed_fire_boundary(0.64, 0.80));
assert!(undo_hold_crossed_fire_boundary(0.80, 0.82));
}
#[test]
fn undo_hold_state_accumulates_and_resets_on_release() {
let mut app = headless_app();
let button = app
.world_mut()
.spawn((UndoButton, Interaction::Pressed))
.id();
app.update();
app.update();
app.update();
assert!(
app.world().resource::<UndoHoldState>().held_secs > 0.0,
"held time must accumulate while the Undo button stays pressed"
);
app.world_mut().entity_mut(button).insert(Interaction::None);
app.update();
assert_eq!(
app.world().resource::<UndoHoldState>().held_secs,
0.0,
"releasing the Undo button must reset the hold state"
);
}
+5 -5
View File
@@ -554,11 +554,11 @@ pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) { pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
if USE_TOUCH_UI_LAYOUT { if USE_TOUCH_UI_LAYOUT {
// Tight 3 px horizontal padding keeps the compact buttons narrow; // Tight 3 px horizontal padding (down from 4) trims 14 px off the row
// Phase F trimmed the touch bar to 5 buttons (2 compact + 3 // total across 7 buttons, and a 44 px min_width (down from 52) lets the
// primary), so width pressure is lower than the old 7-button row. // shortest labels ("New", "Help") shrink to their text rather than
// min_height stays at 44 px to preserve the comfortable touch // padding the row out past the 900 logical-px viewport. min_height
// target on the compact buttons. // stays at 44 px to preserve the comfortable touch target.
( (
UiRect::axes(Val::Px(3.0), Val::Px(4.0)), UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
Val::Px(44.0), Val::Px(44.0),
+3 -36
View File
@@ -136,22 +136,11 @@ impl Plugin for InputPlugin {
.add_systems( .add_systems(
Update, Update,
( (
// The three `DrawRequestEvent` writers join the handle_keyboard_core,
// self-ambiguous `DrawRequestWriters` set so the HUD's
// touch Draw button (hud_plugin, Phase F) can write the
// same queue without tripping the ambiguity gate. The
// `.chain()` still orders them relative to each other.
handle_keyboard_core
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_keyboard_hint, handle_keyboard_hint,
handle_keyboard_forfeit, handle_keyboard_forfeit,
handle_stock_click handle_stock_click,
.in_set(crate::game_plugin::DrawRequestWriters) handle_touch_stock_tap,
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_touch_stock_tap
.in_set(crate::game_plugin::DrawRequestWriters)
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
handle_double_click, handle_double_click,
// Mouse drag pipeline. // Mouse drag pipeline.
start_drag, start_drag,
@@ -215,7 +204,6 @@ fn handle_keyboard_core(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
progress: Option<Res<ProgressResource>>, progress: Option<Res<ProgressResource>>,
drag: Res<DragState>,
mut ev: CoreKeyboardMessages<'_>, mut ev: CoreKeyboardMessages<'_>,
mut time_attack: Option<ResMut<TimeAttackResource>>, mut time_attack: Option<ResMut<TimeAttackResource>>,
selection: Option<Res<SelectionState>>, selection: Option<Res<SelectionState>>,
@@ -228,19 +216,6 @@ fn handle_keyboard_core(
return; return;
} }
// Mutual exclusion with pointer drags — mirrors `handle_selection_keys`.
// Undo / draw / new-game during an active mouse or touch drag would fire
// a StateChangedEvent whose card re-sync inserts a `CardAnim` on the
// dragged cards, fighting `follow_drag`'s per-frame Transform writes and
// leaving `DragState` origin indices stale against the mutated state.
// The keyboard-drag sentinel may proceed: `clear_selection_on_state_change`
// already drops that lift cleanly when the state moves.
if !drag.is_idle()
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
{
return;
}
// During replay playback (Playing or Completed) all game-input shortcuts // During replay playback (Playing or Completed) all game-input shortcuts
// are suppressed. The replay overlay owns Space (pause/resume) and the // are suppressed. The replay overlay owns Space (pause/resume) and the
// arrow keys (step). Letting game input through would mutate // arrow keys (step). Letting game input through would mutate
@@ -487,19 +462,11 @@ pub fn emit_hint_visuals(
fn handle_keyboard_forfeit( fn handle_keyboard_forfeit(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
drag: Res<DragState>,
mut requests: MessageWriter<ForfeitRequestEvent>, mut requests: MessageWriter<ForfeitRequestEvent>,
) { ) {
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
// Same pointer-drag exclusion as `handle_keyboard_core` — forfeiting
// mid-drag would mutate the game state under the dragged cards.
if !drag.is_idle()
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
{
return;
}
if !keys.just_pressed(KeyCode::KeyG) { if !keys.just_pressed(KeyCode::KeyG) {
return; return;
} }
-120
View File
@@ -817,123 +817,3 @@ fn pressing_h_spawns_pending_hint_task() {
"pressing H must spawn an async hint task", "pressing H must spawn an async hint task",
); );
} }
// -----------------------------------------------------------------------
// Pointer-drag / keyboard mutual exclusion.
//
// A state mutation fired while `follow_drag` is writing the dragged cards'
// transforms every frame would make the card re-sync insert a `CardAnim`
// on the same entities (two systems fighting over `Transform`) and leave
// `DragState`'s origin indices stale. `handle_keyboard_core` and
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
// drops that lift cleanly when the state moves.
// -----------------------------------------------------------------------
fn keyboard_core_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<UndoRequestEvent>();
app.add_message::<NewGameRequestEvent>();
app.add_message::<InfoToastEvent>();
app.add_message::<DrawRequestEvent>();
app.add_message::<StartZenRequestEvent>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<DragState>();
app.add_systems(Update, handle_keyboard_core);
app
}
fn press_key(app: &mut App, key: KeyCode) {
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(key);
input.clear();
input.press(key);
}
fn message_count<M: Message>(app: &App) -> usize {
let messages = app.world().resource::<Messages<M>>();
let mut cursor = messages.get_cursor();
cursor.read(messages).count()
}
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
let mut drag = app.world_mut().resource_mut::<DragState>();
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
drag.committed = true;
drag.active_touch_id = active_touch_id;
}
#[test]
fn u_key_fires_undo_when_no_drag_active() {
let mut app = keyboard_core_app();
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
1,
"U with an idle DragState must fire UndoRequestEvent",
);
}
#[test]
fn u_key_ignored_during_committed_mouse_drag() {
let mut app = keyboard_core_app();
set_drag(&mut app, None); // mouse drag
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
0,
"U during a mouse drag must be swallowed",
);
}
#[test]
fn draw_key_ignored_during_touch_drag() {
let mut app = keyboard_core_app();
set_drag(&mut app, Some(7)); // real touch id
press_key(&mut app, KeyCode::KeyD);
app.update();
assert_eq!(
message_count::<DrawRequestEvent>(&app),
0,
"D during a touch drag must be swallowed",
);
}
#[test]
fn u_key_allowed_during_keyboard_lift() {
let mut app = keyboard_core_app();
set_drag(
&mut app,
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
);
press_key(&mut app, KeyCode::KeyU);
app.update();
assert_eq!(
message_count::<UndoRequestEvent>(&app),
1,
"the keyboard-lift sentinel must not block core shortcuts",
);
}
#[test]
fn g_key_forfeit_ignored_during_mouse_drag() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_message::<ForfeitRequestEvent>();
app.init_resource::<ButtonInput<KeyCode>>();
app.init_resource::<DragState>();
app.add_systems(Update, handle_keyboard_forfeit);
set_drag(&mut app, None);
press_key(&mut app, KeyCode::KeyG);
app.update();
assert_eq!(
message_count::<ForfeitRequestEvent>(&app),
0,
"G during a mouse drag must be swallowed",
);
}
+30 -26
View File
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath}; use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::sync_plugin::SyncProviderResource; use crate::sync_plugin::SyncProviderResource;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal, ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_actions, spawn_modal_button, spawn_modal_header, spawn_modal_button, spawn_modal_header,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY, ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
Z_PAUSE_DIALOG, Z_MODAL_PANEL, Z_PAUSE_DIALOG,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -536,6 +536,11 @@ fn spawn_leaderboard_screen(
font_size: TYPE_CAPTION, font_size: TYPE_CAPTION,
..default() ..default()
}; };
let font_status = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_row = TextFont { let font_row = TextFont {
font: font_handle.clone(), font: font_handle.clone(),
font_size: TYPE_BODY, font_size: TYPE_BODY,
@@ -642,31 +647,30 @@ fn spawn_leaderboard_screen(
.with_children(|body| { .with_children(|body| {
match data { match data {
LeaderboardResource::Idle => { LeaderboardResource::Idle => {
spawn_empty_state( body.spawn((
body, Text::new("Fetching\u{2026}"),
"\u{21BB}", font_status.clone(),
"Fetching the leaderboard\u{2026}", TextColor(STATE_INFO),
None, ));
font_res,
);
} }
LeaderboardResource::Error(_) => { LeaderboardResource::Error(_) => {
spawn_empty_state( body.spawn((
body, Text::new("Couldn't reach the leaderboard. Try again later."),
"!", font_status.clone(),
"Couldn't reach the leaderboard.", TextColor(TEXT_SECONDARY),
Some("Try again later."), ));
font_res,
);
} }
LeaderboardResource::Loaded(rows) if rows.is_empty() => { LeaderboardResource::Loaded(rows) if rows.is_empty() => {
spawn_empty_state( body.spawn((
body, Text::new("Be the first on the leaderboard."),
"\u{2660}", font_status.clone(),
"Be the first on the leaderboard.", TextColor(TEXT_PRIMARY),
Some("Win a game and opt in to appear here."), ));
font_res, body.spawn((
); Text::new("Win a game and opt in to appear here."),
font_row.clone(),
TextColor(TEXT_SECONDARY),
));
} }
LeaderboardResource::Loaded(rows) => { LeaderboardResource::Loaded(rows) => {
// Column headers // Column headers
-8
View File
@@ -15,8 +15,6 @@ pub mod avatar_plugin;
pub mod card_animation; pub mod card_animation;
pub mod card_plugin; pub mod card_plugin;
pub mod challenge_plugin; pub mod challenge_plugin;
pub mod cheat_sheet_plugin;
pub mod contextual_tips_plugin;
pub mod core_game_plugin; pub mod core_game_plugin;
pub mod cursor_plugin; pub mod cursor_plugin;
pub mod daily_challenge_plugin; pub mod daily_challenge_plugin;
@@ -28,7 +26,6 @@ pub mod font_plugin;
pub mod game_plugin; pub mod game_plugin;
pub mod help_plugin; pub mod help_plugin;
pub mod home_plugin; pub mod home_plugin;
pub mod hotkeys;
pub mod hud_plugin; pub mod hud_plugin;
pub mod input_plugin; pub mod input_plugin;
pub mod layout; pub mod layout;
@@ -63,12 +60,10 @@ pub mod theme_store_plugin;
pub mod time_attack_plugin; pub mod time_attack_plugin;
pub mod touch_selection_plugin; pub mod touch_selection_plugin;
pub mod ui_focus; pub mod ui_focus;
pub mod ui_glass;
pub mod ui_modal; pub mod ui_modal;
pub mod ui_theme; pub mod ui_theme;
pub mod ui_tooltip; pub mod ui_tooltip;
pub mod weekly_goals_plugin; pub mod weekly_goals_plugin;
pub mod whats_new_plugin;
pub mod win_summary_plugin; pub mod win_summary_plugin;
pub mod you_hub_plugin; pub mod you_hub_plugin;
@@ -98,8 +93,6 @@ pub use card_plugin::{
pub use challenge_plugin::{ pub use challenge_plugin::{
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label, CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
}; };
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
pub use contextual_tips_plugin::ContextualTipsPlugin;
pub use core_game_plugin::CoreGamePlugin; pub use core_game_plugin::CoreGamePlugin;
pub use cursor_plugin::CursorPlugin; pub use cursor_plugin::CursorPlugin;
pub use daily_challenge_plugin::{ pub use daily_challenge_plugin::{
@@ -201,7 +194,6 @@ pub use ui_modal::{
}; };
pub use ui_tooltip::{Tooltip, UiTooltipPlugin}; pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin}; pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
pub use win_summary_plugin::{ pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time, ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
}; };
+82 -33
View File
@@ -84,11 +84,66 @@ struct OnboardingSkipButton;
pub struct OnboardingSlideIndex(pub u8); pub struct OnboardingSlideIndex(pub u8);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Slide data — the hotkey slide renders the essential subset of the // Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
// shared table in `crate::hotkeys` (Phase J unified the previously // two screens stay in sync without a shared abstraction.
// diverging copies here and in help_plugin).
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// A single `key — description` pair shown on slide 3.
#[cfg(not(target_os = "android"))]
struct HotkeyRow {
keys: &'static str,
description: &'static str,
}
/// Most-used shortcuts from the `help_plugin` canonical list.
///
/// Updating the list in `help_plugin.rs` should be mirrored here. The
/// ARCHITECTURE.md decision log calls out that we copy values rather than
/// refactor the help plugin.
#[cfg(not(target_os = "android"))]
const HOTKEYS: &[HotkeyRow] = &[
HotkeyRow {
keys: "D / Space",
description: "Draw from stock",
},
HotkeyRow {
keys: "U",
description: "Undo last move",
},
HotkeyRow {
keys: "Tab → Enter",
description: "Pick a card; arrows pick where; Enter to drop",
},
HotkeyRow {
keys: "N",
description: "New Classic game",
},
HotkeyRow {
keys: "M",
description: "Open Mode Launcher (then 15 to pick)",
},
HotkeyRow {
keys: "S",
description: "Stats & progression",
},
HotkeyRow {
keys: "A",
description: "Achievements",
},
HotkeyRow {
keys: "O",
description: "Settings",
},
HotkeyRow {
keys: "Esc",
description: "Pause / resume",
},
HotkeyRow {
keys: "F1",
description: "Help / controls",
},
];
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -258,10 +313,6 @@ fn complete_onboarding(
despawn_screen(commands, screens); despawn_screen(commands, screens);
if let Some(s) = settings { if let Some(s) = settings {
s.0.first_run_complete = true; s.0.first_run_complete = true;
// A fresh install has nothing "new" to announce — stamp the
// running release so the What's-new card (Phase I) only ever
// fires after an actual upgrade.
s.0.last_seen_whats_new = crate::whats_new_plugin::current_release_version();
persist(path.map(|p| &p.0), &s.0); persist(path.map(|p| &p.0), &s.0);
} }
} }
@@ -331,27 +382,20 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>)
}); });
} }
/// How-to-play body copy, phrased for the platform's input vocabulary —
/// a touch player never left-clicks (Phase H polish; spotted in the
/// emulator smoke of v0.44.0).
#[cfg(target_os = "android")]
const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-tap a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Tap Hint in the bottom bar for a suggested move.";
#[cfg(not(target_os = "android"))]
const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-click a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Right-click a card for a hint valid destinations will highlight.";
/// Slide 2 — How to play. /// Slide 2 — How to play.
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) { fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Drag cards to play", font_res); spawn_modal_header(card, "Drag cards to play", font_res);
spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res); spawn_modal_body_text(
card,
"Left-click and drag any face-up card to move it between piles. \
You can drag a whole column at once by grabbing the topmost card \
you want to move. Double-click a face-up card to send it to a \
foundation pile automatically (when the move is legal). \
Right-click a card for a hint valid destinations will highlight.",
TEXT_SECONDARY,
font_res,
);
spawn_modal_actions(card, |actions| { spawn_modal_actions(card, |actions| {
spawn_modal_button( spawn_modal_button(
actions, actions,
@@ -391,10 +435,8 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| { spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
spawn_modal_header(card, "Keyboard shortcuts", font_res); spawn_modal_header(card, "Keyboard shortcuts", font_res);
// Vertical list of `key — description` rows, same chip style as // Vertical list of `key — description` rows, same chip style as HelpScreen.
// HelpScreen. Essential subset only — the full table lives on for row in HOTKEYS {
// the hold-`/` cheat sheet.
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
card.spawn(Node { card.spawn(Node {
flex_direction: FlexDirection::Row, flex_direction: FlexDirection::Row,
align_items: AlignItems::Center, align_items: AlignItems::Center,
@@ -773,15 +815,22 @@ mod tests {
} }
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
// Hotkey slide renders a non-empty essential subset (the table's own // Hotkey list is non-empty (guards against accidental truncation)
// integrity tests live in `crate::hotkeys`)
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
#[test] #[test]
fn hotkey_slide_subset_is_non_empty() { fn hotkey_list_is_non_empty() {
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
}
#[test]
fn all_hotkey_rows_have_non_empty_fields() {
for row in HOTKEYS {
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
assert!( assert!(
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential), !row.description.is_empty(),
"the onboarding slide needs at least one essential hotkey" "hotkey description must not be empty"
); );
} }
}
} }
+7 -28
View File
@@ -101,10 +101,9 @@ impl Plugin for SafeAreaInsetsPlugin {
fn apply_safe_area_anchors( fn apply_safe_area_anchors(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>, mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
) { ) {
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) { if !insets.is_changed() {
return; return;
} }
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px // Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
@@ -120,31 +119,19 @@ fn apply_safe_area_anchors(
); );
} }
let top_logical = raw_top.min(max_inset); let top_logical = raw_top.min(max_inset);
// `bevy::ui::UiScale` (Phase K) multiplies every Val::Px at layout
// time. `base_top` is UI chrome and SHOULD scale with the rest of
// the interface, but the system-bar inset is physical reality —
// pre-divide it so the post-multiplication offset stays exact.
let ui = effective_ui_scale(ui_scale.as_deref());
for (anchor, mut node) in &mut q { for (anchor, mut node) in &mut q {
node.top = Val::Px(anchor.base_top + top_logical / ui); node.top = Val::Px(anchor.base_top + top_logical);
} }
} }
/// The live UI scale, defensively clamped — a zero or negative scale
/// would flip or destroy the inset math.
fn effective_ui_scale(ui_scale: Option<&UiScale>) -> f32 {
ui_scale.map_or(1.0, |s| s.0.max(0.1))
}
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying /// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes. /// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
fn apply_safe_area_bottom_anchors( fn apply_safe_area_bottom_anchors(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>, mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
) { ) {
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) { if !insets.is_changed() {
return; return;
} }
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
@@ -157,12 +144,8 @@ fn apply_safe_area_bottom_anchors(
); );
} }
let bottom_logical = raw_bottom.min(max_inset); let bottom_logical = raw_bottom.min(max_inset);
// See `apply_safe_area_anchors`: the physical inset is pre-divided
// by the UI scale so a 90% setting can't sink the action bar into
// the gesture zone (and 130% doesn't over-inset it).
let ui = effective_ui_scale(ui_scale.as_deref());
for (anchor, mut node) in &mut q { for (anchor, mut node) in &mut q {
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui); node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
} }
} }
@@ -180,23 +163,19 @@ fn apply_safe_area_bottom_anchors(
fn apply_safe_area_to_modal_scrims( fn apply_safe_area_to_modal_scrims(
insets: Res<SafeAreaInsets>, insets: Res<SafeAreaInsets>,
windows: Query<&Window>, windows: Query<&Window>,
ui_scale: Option<Res<UiScale>>,
mut scrims: Query<&mut Node, With<ModalScrim>>, mut scrims: Query<&mut Node, With<ModalScrim>>,
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>, new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
) { ) {
let has_new = !new_scrims.is_empty(); let has_new = !new_scrims.is_empty();
if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) { if !insets.is_changed() && !has_new {
return; return;
} }
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor()); let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
let window_height = windows.iter().next().map_or(800.0, |w| w.height()); let window_height = windows.iter().next().map_or(800.0, |w| w.height());
// Clamp each inset to 25% of screen height so an unexpectedly large OS // Clamp each inset to 25% of screen height so an unexpectedly large OS
// value can't push the modal card off the visible area entirely. // value can't push the modal card off the visible area entirely.
// Physical insets are pre-divided by the UI scale — see let top_logical = (insets.top / scale).min(window_height * 0.25);
// `apply_safe_area_anchors`. let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
let ui = effective_ui_scale(ui_scale.as_deref());
let top_logical = (insets.top / scale).min(window_height * 0.25) / ui;
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25) / ui;
for mut node in &mut scrims { for mut node in &mut scrims {
// Set both edges so the scrim's content box equals the usable area // Set both edges so the scrim's content box equals the usable area
// between the status bar and the gesture/navigation bar. With // between the status bar and the gesture/navigation bar. With
@@ -363,14 +363,6 @@ pub(super) fn handle_settings_buttons(
changed.write(SettingsChangedEvent(settings.0.clone())); changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_touch_input_mode_text` next frame. // Text refreshed by `update_touch_input_mode_text` next frame.
} }
SettingsButton::CycleUiScale => {
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_ui_scale_text`; the live
// `bevy::ui::UiScale` resource follows via
// `sync_ui_scale_resource` next frame.
}
SettingsButton::ToggleWinnableDealsOnly => { SettingsButton::ToggleWinnableDealsOnly => {
settings.0.winnable_deals_only = !settings.0.winnable_deals_only; settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
persist(&path, &settings.0); persist(&path, &settings.0);
@@ -139,10 +139,6 @@ struct ReduceMotionText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TouchInputModeText; struct TouchInputModeText;
/// Marks the `Text` node showing the current UI scale percentage.
#[derive(Component, Debug)]
struct UiScaleText;
/// Marks the `Text` node showing the live tooltip-delay value. /// Marks the `Text` node showing the live tooltip-delay value.
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct TooltipDelayText; struct TooltipDelayText;
@@ -281,10 +277,6 @@ enum SettingsButton {
/// (auto-move on tap, default) and `TapToSelect` (first tap selects /// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it). /// a card/stack, second tap on a target pile moves it).
ToggleTouchInputMode, ToggleTouchInputMode,
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
/// stays window-fit (Phase K).
CycleUiScale,
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new /// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
/// random Classic-mode deals are filtered through /// random Classic-mode deals are filtered through
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably /// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
@@ -362,7 +354,6 @@ impl SettingsButton {
SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62, SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63, SettingsButton::ToggleTouchInputMode => 63,
SettingsButton::CycleUiScale => 64,
// Picker rows — every swatch in a row shares the row's // Picker rows — every swatch in a row shares the row's
// priority so entity-index tiebreaking yields left → right. // priority so entity-index tiebreaking yields left → right.
SettingsButton::SelectCardBack(_) => 70, SettingsButton::SelectCardBack(_) => 70,
@@ -458,11 +449,6 @@ impl Plugin for SettingsPlugin {
handle_volume_keys, handle_volume_keys,
record_window_geometry_changes, record_window_geometry_changes,
persist_window_geometry_after_debounce, persist_window_geometry_after_debounce,
// State sync, not UI — runs even under `headless()`
// so the live `bevy::ui::UiScale` always mirrors the
// setting; rides the mutator spine so it is ordered
// after every settings writer this frame.
sync_ui_scale_resource,
) )
.chain() .chain()
.in_set(SettingsMutation), .in_set(SettingsMutation),
@@ -506,7 +492,6 @@ impl Plugin for SettingsPlugin {
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>), update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
update_reduce_motion_text, update_reduce_motion_text,
update_touch_input_mode_text, update_touch_input_mode_text,
update_ui_scale_text,
update_tooltip_delay_text, update_tooltip_delay_text,
update_time_bonus_multiplier_text, update_time_bonus_multiplier_text,
update_replay_move_interval_text, update_replay_move_interval_text,
@@ -736,60 +736,3 @@ fn scroll_clamps_offset_to_zero_at_top() {
"scrolling past top must clamp to 0, got {offset}" "scrolling past top must clamp to 0, got {offset}"
); );
} }
// ---------------------------------------------------------------------------
// Phase K: UI scale
// ---------------------------------------------------------------------------
#[test]
fn ui_scale_steps_cycle_and_wrap() {
assert_eq!(next_ui_scale(0.9), 1.0);
assert_eq!(next_ui_scale(1.0), 1.15);
assert_eq!(next_ui_scale(1.15), 1.3);
assert_eq!(next_ui_scale(1.3), 0.9, "the cycle must wrap");
// A hand-edited in-between value advances to the next larger step.
assert_eq!(next_ui_scale(1.05), 1.15);
}
#[test]
fn ui_scale_label_formats_as_percent() {
assert_eq!(ui_scale_label(0.9), "90%");
assert_eq!(ui_scale_label(1.0), "100%");
assert_eq!(ui_scale_label(1.15), "115%");
assert_eq!(ui_scale_label(1.3), "130%");
}
#[test]
fn ui_scale_setting_syncs_the_bevy_resource() {
let mut app = headless_app();
app.insert_resource(UiScale(1.0));
app.world_mut()
.resource_mut::<SettingsResource>()
.0
.ui_scale = 1.3;
app.update();
assert!(
(app.world().resource::<UiScale>().0 - 1.3).abs() < f32::EPSILON,
"Settings::ui_scale must drive bevy::ui::UiScale"
);
}
#[test]
fn ui_scale_out_of_range_sanitizes_on_load() {
use solitaire_data::settings::{UI_SCALE_MAX, UI_SCALE_MIN};
let wild = Settings {
ui_scale: 5.0,
..Settings::default()
}
.sanitized();
assert_eq!(wild.ui_scale, UI_SCALE_MAX);
let tiny = Settings {
ui_scale: 0.1,
..Settings::default()
}
.sanitized();
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
}
@@ -326,15 +326,6 @@ fn spawn_accessibility_tab(
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.", "One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
font_res, font_res,
); );
toggle_row(
body,
"UI Scale",
UiScaleText,
ui_scale_label(settings.ui_scale),
SettingsButton::CycleUiScale,
"Scales all menus, buttons, and HUD text. The table itself always fits the screen.",
font_res,
);
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res); tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
} }
@@ -515,58 +515,6 @@ pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInput
} }
} }
/// The four UI-scale steps the settings row cycles through (Phase K).
pub(super) const UI_SCALE_STEPS: [f32; 4] = [0.9, 1.0, 1.15, 1.3];
/// The next UI-scale step after `current`, wrapping 130 % → 90 %. A
/// hand-edited value between steps advances to the first step larger
/// than it, so the cycle always makes visible progress.
pub(super) fn next_ui_scale(current: f32) -> f32 {
for step in UI_SCALE_STEPS {
if step > current + 0.001 {
return step;
}
}
UI_SCALE_STEPS[0]
}
/// Display string for the UI-scale row, e.g. `"115%"`.
pub(super) fn ui_scale_label(scale: f32) -> String {
format!("{:.0}%", scale * 100.0)
}
/// Refreshes the live UI-scale value text whenever settings change.
pub(super) fn update_ui_scale_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<UiScaleText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = ui_scale_label(settings.0.ui_scale);
}
}
/// Applies `Settings::ui_scale` to the live [`bevy::ui::UiScale`]
/// resource — at startup (first change tick) and whenever the setting
/// changes. Absent under `MinimalPlugins` (no `bevy_ui`), hence the
/// `Option`; the table is unaffected either way (`compute_layout`
/// owns world-space sizing, not UI scale).
pub(super) fn sync_ui_scale_resource(
settings: Res<SettingsResource>,
ui_scale: Option<ResMut<UiScale>>,
) {
let Some(mut ui_scale) = ui_scale else { return };
if !settings.is_changed() {
return;
}
let target = settings.0.ui_scale;
if (ui_scale.0 - target).abs() > f32::EPSILON {
ui_scale.0 = target;
}
}
/// Display string for the "Smart window size" toggle. The argument /// Display string for the "Smart window size" toggle. The argument
/// is the *enabled* state (i.e. the inverse of the underlying /// is the *enabled* state (i.e. the inverse of the underlying
/// `disable_smart_default_size` field) so reading the label gives /// `disable_smart_default_size` field) so reading the label gives
+17 -23
View File
@@ -30,7 +30,7 @@ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO, ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
}; };
/// Bevy resource wrapping the current stats. /// Bevy resource wrapping the current stats.
@@ -719,17 +719,25 @@ pub(crate) fn spawn_stats_body(
}, },
)) ))
.with_children(|body| { .with_children(|body| {
// First-launch empty state (Phase L) — sits above the grid so // First-launch caption — sits above the grid as gentle nudge so
// the wall of em-dashes reads as "nothing to track yet" rather // the wall of em-dashes reads as "nothing to track yet" rather
// than as broken state. // than as broken state.
if is_first_launch { if is_first_launch {
crate::ui_modal::spawn_empty_state( body.spawn((
body, Text::new("Play a game to start tracking stats."),
"\u{2663}", TextFont {
"Play a game to start tracking stats.", font_size: TYPE_CAPTION,
None, ..default()
font_res, },
); TextColor(TEXT_SECONDARY),
Node {
margin: UiRect {
bottom: VAL_SPACE_2,
..default()
},
..default()
},
));
} }
// --- primary stat cells grid --- // --- primary stat cells grid ---
@@ -885,20 +893,6 @@ pub(crate) fn spawn_replays_body(
selected_index: usize, selected_index: usize,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
// Standard empty state (Phase L) — the selector / Watch / Copy
// controls only spawn when there is something to select, so their
// handlers all no-op via their existing empty-query paths.
if replays.is_empty() {
crate::ui_modal::spawn_empty_state(
card,
"\u{2666}",
"No replays yet.",
Some("Every win records a replay here automatically."),
font_res,
);
return;
}
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default(); let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_row = TextFont { let font_row = TextFont {
font: font_handle, font: font_handle,
+24 -266
View File
@@ -14,9 +14,6 @@
//! filesystem-based; the plugin is gated out on wasm32 alongside //! filesystem-based; the plugin is gated out on wasm32 alongside
//! `SyncPlugin`. //! `SyncPlugin`.
use std::collections::HashMap;
use bevy::asset::RenderAssetUsages;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future}; use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
use thiserror::Error; use thiserror::Error;
@@ -32,8 +29,8 @@ use crate::resources::TokioRuntimeResource;
use crate::settings_plugin::{SettingsPanel, SettingsResource}; use crate::settings_plugin::{SettingsPanel, SettingsResource};
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry}; use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal, ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_actions, spawn_modal_button, spawn_modal_header, spawn_modal_button, spawn_modal_header,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
@@ -108,21 +105,6 @@ struct InstallTask(Option<(String, Task<InstallResult>)>);
#[derive(Resource, Default)] #[derive(Resource, Default)]
struct StoreBaseUrl(Option<String>); struct StoreBaseUrl(Option<String>);
/// Decoded preview thumbnails by theme id (Phase H). Session-lifetime:
/// previews survive closing and reopening the store, so a revisit
/// renders instantly without refetching.
#[derive(Resource, Default)]
struct PreviewCache(HashMap<String, Handle<Image>>);
/// One in-flight preview download: raw PNG bytes or the fetch error.
type PreviewFetch = Task<Result<Vec<u8>, ThemeStoreError>>;
/// In-flight preview downloads, one per catalog entry that advertises
/// a `preview_url` not already in [`PreviewCache`]. `String` is the
/// theme id the bytes belong to.
#[derive(Resource, Default)]
struct PreviewTasks(Vec<(String, PreviewFetch)>);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Plugin // Plugin
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -136,8 +118,6 @@ impl Plugin for ThemeStorePlugin {
.init_resource::<CatalogTask>() .init_resource::<CatalogTask>()
.init_resource::<InstallTask>() .init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>() .init_resource::<StoreBaseUrl>()
.init_resource::<PreviewCache>()
.init_resource::<PreviewTasks>()
// Esc-close reads keyboard input; register defensively so // Esc-close reads keyboard input; register defensively so
// the plugin works under MinimalPlugins in tests. // the plugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>() .init_resource::<ButtonInput<KeyCode>>()
@@ -149,7 +129,6 @@ impl Plugin for ThemeStorePlugin {
( (
handle_open_request, handle_open_request,
poll_catalog_task, poll_catalog_task,
poll_preview_tasks,
handle_install_buttons, handle_install_buttons,
poll_install_task, poll_install_task,
handle_close_button, handle_close_button,
@@ -197,7 +176,6 @@ fn handle_open_request(
mut catalog_task: ResMut<CatalogTask>, mut catalog_task: ResMut<CatalogTask>,
mut store_base: ResMut<StoreBaseUrl>, mut store_base: ResMut<StoreBaseUrl>,
mut warning_toast: MessageWriter<WarningToastEvent>, mut warning_toast: MessageWriter<WarningToastEvent>,
previews: Res<PreviewCache>,
mut commands: Commands, mut commands: Commands,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -229,28 +207,16 @@ fn handle_open_request(
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await }) rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
})); }));
spawn_store_modal( spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
&mut commands,
&catalog_state,
None,
&previews,
font_res.as_deref(),
);
} }
/// Polls the catalog fetch; on completion updates [`CatalogState`], /// Polls the catalog fetch; on completion updates [`CatalogState`] and
/// kicks off preview downloads for entries not yet in the cache, and
/// rebuilds the modal if it is still open. /// rebuilds the modal if it is still open.
#[allow(clippy::too_many_arguments)]
fn poll_catalog_task( fn poll_catalog_task(
mut catalog_task: ResMut<CatalogTask>, mut catalog_task: ResMut<CatalogTask>,
mut catalog_state: ResMut<CatalogState>, mut catalog_state: ResMut<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>, screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>, registry: Option<Res<ThemeRegistry>>,
rt: Option<Res<TokioRuntimeResource>>,
store_base: Res<StoreBaseUrl>,
previews: Res<PreviewCache>,
mut preview_tasks: ResMut<PreviewTasks>,
mut commands: Commands, mut commands: Commands,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -263,29 +229,7 @@ fn poll_catalog_task(
catalog_task.0 = None; catalog_task.0 = None;
*catalog_state = match result { *catalog_state = match result {
Ok(entries) => { Ok(entries) => CatalogState::Loaded(entries),
// Fetch previews for anything new. Decorative — failures
// just leave the row text-only, so errors only log.
if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) {
for entry in entries
.iter()
.filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id))
{
let rt = rt.0.clone();
let base_url = base_url.to_owned();
let entry = entry.clone();
preview_tasks.0.push((
entry.id.clone(),
AsyncComputeTaskPool::get().spawn(async move {
rt.block_on(async {
ThemeStoreClient::new(base_url).fetch_preview(&entry).await
})
}),
));
}
}
CatalogState::Loaded(entries)
}
Err(e) => { Err(e) => {
warn!("theme store: catalog fetch failed: {e}"); warn!("theme store: catalog fetch failed: {e}");
CatalogState::Error(e.to_string()) CatalogState::Error(e.to_string())
@@ -297,69 +241,10 @@ fn poll_catalog_task(
&mut commands, &mut commands,
&catalog_state, &catalog_state,
registry.as_deref(), registry.as_deref(),
&previews,
font_res.as_deref(), font_res.as_deref(),
); );
} }
/// Polls in-flight preview downloads. Each finished PNG is decoded into
/// an [`Image`] asset and cached by theme id; the open modal rebuilds
/// once per frame that added at least one preview, so thumbnails pop in
/// as they arrive. Failures log and leave the row text-only. Skipped
/// entirely when `Assets<Image>` is absent (`MinimalPlugins` tests).
#[allow(clippy::too_many_arguments)]
fn poll_preview_tasks(
mut preview_tasks: ResMut<PreviewTasks>,
mut previews: ResMut<PreviewCache>,
images: Option<ResMut<Assets<Image>>>,
catalog_state: Res<CatalogState>,
screens: Query<Entity, With<ThemeStoreScreen>>,
registry: Option<Res<ThemeRegistry>>,
mut commands: Commands,
font_res: Option<Res<FontResource>>,
) {
if preview_tasks.0.is_empty() {
return;
}
let Some(mut images) = images else {
return;
};
let mut added = false;
preview_tasks.0.retain_mut(|(id, task)| {
let Some(result) = future::block_on(future::poll_once(task)) else {
return true; // still downloading
};
match result {
Ok(bytes) => match image::load_from_memory(&bytes) {
Ok(dyn_img) => {
let handle = images.add(Image::from_dynamic(
dyn_img,
true,
RenderAssetUsages::RENDER_WORLD,
));
previews.0.insert(id.clone(), handle);
added = true;
}
Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"),
},
Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"),
}
false
});
if added {
rebuild_open_modal(
&screens,
&mut commands,
&catalog_state,
registry.as_deref(),
&previews,
font_res.as_deref(),
);
}
}
/// Starts a download + import task when an Install button is pressed. /// Starts a download + import task when an Install button is pressed.
/// One install at a time; repeat clicks while busy are ignored. /// One install at a time; repeat clicks while busy are ignored.
fn handle_install_buttons( fn handle_install_buttons(
@@ -423,7 +308,6 @@ fn poll_install_task(
screens: Query<Entity, With<ThemeStoreScreen>>, screens: Query<Entity, With<ThemeStoreScreen>>,
mut info_toast: MessageWriter<InfoToastEvent>, mut info_toast: MessageWriter<InfoToastEvent>,
mut warning_toast: MessageWriter<WarningToastEvent>, mut warning_toast: MessageWriter<WarningToastEvent>,
previews: Res<PreviewCache>,
mut commands: Commands, mut commands: Commands,
font_res: Option<Res<FontResource>>, font_res: Option<Res<FontResource>>,
) { ) {
@@ -459,7 +343,6 @@ fn poll_install_task(
&mut commands, &mut commands,
&catalog_state, &catalog_state,
registry.as_deref(), registry.as_deref(),
&previews,
font_res.as_deref(), font_res.as_deref(),
); );
} }
@@ -495,12 +378,11 @@ fn rebuild_open_modal(
commands: &mut Commands, commands: &mut Commands,
catalog_state: &CatalogState, catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>, registry: Option<&ThemeRegistry>,
previews: &PreviewCache,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
for entity in screens { for entity in screens {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
spawn_store_modal(commands, catalog_state, registry, previews, font_res); spawn_store_modal(commands, catalog_state, registry, font_res);
} }
} }
@@ -509,7 +391,6 @@ fn spawn_store_modal(
commands: &mut Commands, commands: &mut Commands,
catalog_state: &CatalogState, catalog_state: &CatalogState,
registry: Option<&ThemeRegistry>, registry: Option<&ThemeRegistry>,
previews: &PreviewCache,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
) { ) {
let body_font = TextFont { let body_font = TextFont {
@@ -528,45 +409,31 @@ fn spawn_store_modal(
match catalog_state { match catalog_state {
CatalogState::Idle | CatalogState::Loading => { CatalogState::Idle | CatalogState::Loading => {
spawn_empty_state( card.spawn((
card, Text::new("Loading catalog…"),
"\u{21BB}", body_font.clone(),
"Loading the catalog\u{2026}", TextColor(TEXT_SECONDARY),
None, ));
font_res,
);
} }
CatalogState::Error(msg) => { CatalogState::Error(msg) => {
spawn_empty_state( card.spawn((
card, Text::new(format!("Could not load the catalog: {msg}")),
"!", body_font.clone(),
"Couldn't load the catalog.", TextColor(TEXT_SECONDARY),
Some(msg.as_str()), ));
font_res,
);
} }
CatalogState::Loaded(entries) if entries.is_empty() => { CatalogState::Loaded(entries) if entries.is_empty() => {
spawn_empty_state( card.spawn((
card, Text::new("The server has no themes yet."),
"#", body_font.clone(),
"The server has no themes yet.", TextColor(TEXT_SECONDARY),
Some("Themes dropped into the server's theme_store folder appear here."), ));
font_res,
);
} }
CatalogState::Loaded(entries) => { CatalogState::Loaded(entries) => {
for entry in entries { for entry in entries {
let installed = let installed =
registry.is_some_and(|registry| registry.find(&entry.id).is_some()); registry.is_some_and(|registry| registry.find(&entry.id).is_some());
spawn_store_row( spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
card,
entry,
installed,
previews.0.get(&entry.id),
&body_font,
&caption_font,
font_res,
);
} }
} }
} }
@@ -585,20 +452,12 @@ fn spawn_store_modal(
commands.entity(scrim).insert(ScrimDismissible); commands.entity(scrim).insert(ScrimDismissible);
} }
/// Height of a row's preview thumbnail in logical pixels; the width /// One catalog row: name + author/size caption on the left, Install
/// follows the theme's own `card_aspect` so a preview is never /// button (or "Installed" caption) on the right.
/// stretched.
const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0;
/// One catalog row: preview thumbnail (when downloaded) + name +
/// author/size caption on the left, Install button (or "Installed"
/// caption) on the right.
#[allow(clippy::too_many_arguments)]
fn spawn_store_row( fn spawn_store_row(
parent: &mut ChildSpawnerCommands, parent: &mut ChildSpawnerCommands,
entry: &ThemeCatalogEntry, entry: &ThemeCatalogEntry,
installed: bool, installed: bool,
preview: Option<&Handle<Image>>,
body_font: &TextFont, body_font: &TextFont,
caption_font: &TextFont, caption_font: &TextFont,
font_res: Option<&FontResource>, font_res: Option<&FontResource>,
@@ -618,25 +477,6 @@ fn spawn_store_row(
)) ))
.with_children(|row| { .with_children(|row| {
row.spawn(Node { row.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|left| {
if let Some(handle) = preview {
let (aw, ah) = entry.card_aspect;
let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32;
left.spawn((
ImageNode::new(handle.clone()),
Node {
width: Val::Px(width),
height: Val::Px(PREVIEW_THUMB_HEIGHT_PX),
..default()
},
));
}
left.spawn(Node {
flex_direction: FlexDirection::Column, flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2, row_gap: VAL_SPACE_2,
..default() ..default()
@@ -657,7 +497,6 @@ fn spawn_store_row(
TextColor(TEXT_SECONDARY), TextColor(TEXT_SECONDARY),
)); ));
}); });
});
if installed { if installed {
row.spawn(( row.spawn((
Text::new("Installed"), Text::new("Installed"),
@@ -822,85 +661,4 @@ mod tests {
)); ));
assert_eq!(screen_count(&mut app), 1); assert_eq!(screen_count(&mut app), 1);
} }
// -----------------------------------------------------------------------
// Phase H part 2: preview thumbnails
// -----------------------------------------------------------------------
fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry {
ThemeCatalogEntry {
id: id.into(),
name: id.into(),
author: "Test".into(),
version: "1.0.0".into(),
card_aspect: (2, 3),
size_bytes: 1024,
sha256: "00".into(),
download_url: format!("/api/themes/{id}/download"),
preview_url: preview.then(|| format!("/api/themes/{id}/preview")),
}
}
/// A row whose theme id is in the [`PreviewCache`] renders an
/// `ImageNode` thumbnail; a row without one stays text-only.
#[test]
fn loaded_row_shows_preview_thumbnail_only_when_cached() {
let mut app = headless_app();
let cached = catalog_entry("with-preview", true);
let uncached = catalog_entry("without-preview", false);
let mut cache = PreviewCache::default();
cache
.0
.insert(cached.id.clone(), Handle::<Image>::default());
let state = CatalogState::Loaded(vec![cached, uncached]);
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_store_modal(&mut commands, &state, None, &cache, None);
}
app.update();
let thumbs = app
.world_mut()
.query::<&ImageNode>()
.iter(app.world())
.count();
assert_eq!(
thumbs, 1,
"exactly the cached entry must render a preview thumbnail"
);
}
/// The thumbnail width follows the theme's own card aspect so a
/// preview never renders stretched.
#[test]
fn preview_thumbnail_width_follows_card_aspect() {
let mut app = headless_app();
let mut entry = catalog_entry("wide", true);
entry.card_aspect = (1, 1); // square theme art
let mut cache = PreviewCache::default();
cache.0.insert(entry.id.clone(), Handle::<Image>::default());
let state = CatalogState::Loaded(vec![entry]);
{
let world = app.world_mut();
let mut commands = world.commands();
spawn_store_modal(&mut commands, &state, None, &cache, None);
}
app.update();
let node = app
.world_mut()
.query_filtered::<&Node, With<ImageNode>>()
.single(app.world())
.expect("thumbnail node must exist");
assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX));
assert_eq!(
node.width,
Val::Px(PREVIEW_THUMB_HEIGHT_PX),
"a 1:1 aspect must yield a square thumbnail"
);
}
} }
-146
View File
@@ -1,146 +0,0 @@
//! "Liquid glass" material for floating UI surfaces.
//!
//! Produces the translucent, light-catching treatment used by the floating
//! touch tab bar (`hud_plugin::tab_bar`): a semi-transparent fill with a
//! vertical sheen, a specular rim highlight around the border, and a soft
//! drop shadow underneath. The table shows through the fill *sharp* — Bevy's
//! UI pipeline has no backdrop-blur concept, so this module approximates
//! frosted glass with layered gradients instead of sampling what's behind
//! the node. That keeps it a plain `bevy_ui` bundle: no custom shaders, no
//! extra render passes, and identical behaviour on desktop, Android, and
//! WebGL2.
//!
//! # Swapping the treatment
//!
//! Layout code depends only on [`glass_surface`] and [`GLASS_BORDER_PX`].
//! A future real-refraction material (e.g. a `UiMaterial` sampling a blurred
//! render target) can replace this module's internals without touching any
//! caller — that boundary is the reason this file exists separately from
//! `hud_plugin/tab_bar.rs`.
//!
//! # Departure from the Terminal design system
//!
//! `ui_theme` documents HUD chrome as opaque "status-line" panels. The glass
//! bar is a deliberate, user-approved exception for the floating touch tab
//! bar only; do not reuse this material for modals or the top HUD band
//! without a matching design decision.
use bevy::prelude::*;
use bevy::ui::{
BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle,
};
/// Border width every glass surface must reserve in its `Node::border` so
/// the rim gradient has a strip to paint into.
pub const GLASS_BORDER_PX: f32 = 1.0;
/// Base fill at the *bottom* of the glass sheet — near-black at ~62%
/// opacity so the felt and cards remain visible through the bar while
/// keeping icon/label contrast comfortable.
const GLASS_FILL_BOTTOM: Color = Color::srgba(0.055, 0.055, 0.063, 0.62);
/// Fill at the *top* of the sheet — the same glass lifted towards white,
/// reading as the curved surface catching overhead light.
const GLASS_FILL_TOP: Color = Color::srgba(0.20, 0.21, 0.23, 0.66);
/// Rim highlight at the top edge — the bright specular line where a real
/// glass pill would catch the light source.
const GLASS_RIM_TOP: Color = Color::srgba(1.0, 1.0, 1.0, 0.38);
/// Rim at the midpoint — almost gone, so the highlight reads as a glint
/// rather than a drawn outline.
const GLASS_RIM_MID: Color = Color::srgba(1.0, 1.0, 1.0, 0.06);
/// Rim at the bottom edge — a faint secondary catch-light, as on the
/// underside of a curved surface above a bright table.
const GLASS_RIM_BOTTOM: Color = Color::srgba(1.0, 1.0, 1.0, 0.16);
/// Drop shadow under the floating surface.
const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35);
/// Visual components for a floating glass surface.
///
/// The caller owns the `Node` and must set two fields for the material to
/// render correctly:
/// - `border: UiRect::all(Val::Px(GLASS_BORDER_PX))` — the rim gradient
/// paints into the border strip and is invisible without it.
/// - `border_radius` — half the node height for a fully-round pill.
///
/// Everything here is fragment-shader work inside Bevy's stock UI pipeline,
/// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes.
pub fn glass_surface() -> impl Bundle {
(
// Sheen: one top-to-bottom linear gradient carries both the fill and
// the lighting so there is a single source of truth for the surface
// colour (a separate `BackgroundColor` would just be painted over).
BackgroundGradient(vec![
LinearGradient::new(
LinearGradient::TO_BOTTOM,
vec![
ColorStop::new(GLASS_FILL_TOP, Val::Percent(0.0)),
ColorStop::new(GLASS_FILL_BOTTOM, Val::Percent(60.0)),
],
)
.into(),
]),
// Specular rim: bright at the top edge, fading out through the
// sides, with a faint return at the bottom.
BorderGradient(vec![
LinearGradient::new(
LinearGradient::TO_BOTTOM,
vec![
ColorStop::new(GLASS_RIM_TOP, Val::Percent(0.0)),
ColorStop::new(GLASS_RIM_MID, Val::Percent(55.0)),
ColorStop::new(GLASS_RIM_BOTTOM, Val::Percent(100.0)),
],
)
.into(),
]),
// Soft shadow underneath sells the "floating above the table" read.
BoxShadow(vec![ShadowStyle {
color: GLASS_SHADOW,
x_offset: Val::Px(0.0),
y_offset: Val::Px(6.0),
spread_radius: Val::Px(0.0),
blur_radius: Val::Px(16.0),
}]),
)
}
#[cfg(test)]
mod tests {
use super::*;
/// The bundle must insert all three visual components — a regression
/// here (e.g. a refactor dropping the rim) would silently flatten the
/// glass into a plain translucent box.
#[test]
fn glass_surface_inserts_sheen_rim_and_shadow() {
let mut world = World::new();
let e = world.spawn(glass_surface()).id();
assert!(world.get::<BackgroundGradient>(e).is_some());
assert!(world.get::<BorderGradient>(e).is_some());
assert!(world.get::<BoxShadow>(e).is_some());
}
/// The fill must stay translucent (that is the whole point of glass) but
/// opaque enough that text keeps contrast over busy card art.
#[test]
fn glass_fill_alpha_stays_in_readable_band() {
for fill in [GLASS_FILL_TOP, GLASS_FILL_BOTTOM] {
let a = fill.alpha();
assert!(
(0.4..=0.85).contains(&a),
"glass fill alpha {a} outside readable 0.40.85 band"
);
}
}
/// The rim gradient must actually glint: strictly brightest at the top,
/// dimmest in the middle.
#[test]
fn rim_highlight_peaks_at_top() {
assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha());
assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.alpha());
}
}
+4 -162
View File
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ use crate::ui_theme::{
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder, BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED, MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY, TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, scaled_duration, VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
}; };
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -178,32 +178,6 @@ pub fn spawn_modal<M: Component, F>(
z_panel: i32, z_panel: i32,
build_card: F, build_card: F,
) -> Entity ) -> Entity
where
F: FnOnce(&mut ChildSpawnerCommands),
{
spawn_modal_sized(
commands,
plugin_marker,
z_panel,
MODAL_CARD_MAX_WIDTH,
build_card,
)
}
/// Default maximum card width in logical pixels — every [`spawn_modal`]
/// caller gets this. Surfaces that lay out side-by-side panes (e.g. the
/// two-pane Home on wide viewports) can raise it via [`spawn_modal_sized`].
pub const MODAL_CARD_MAX_WIDTH: f32 = 720.0;
/// [`spawn_modal`] with an explicit card `max_width`. Behaviour is
/// otherwise identical — same scrim, enter animation, and card chrome.
pub fn spawn_modal_sized<M: Component, F>(
commands: &mut Commands,
plugin_marker: M,
z_panel: i32,
max_width: f32,
build_card: F,
) -> Entity
where where
F: FnOnce(&mut ChildSpawnerCommands), F: FnOnce(&mut ChildSpawnerCommands),
{ {
@@ -261,7 +235,7 @@ where
padding: UiRect::all(VAL_SPACE_5), padding: UiRect::all(VAL_SPACE_5),
border: UiRect::all(Val::Px(1.0)), border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)), border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
max_width: Val::Px(max_width), max_width: Val::Px(720.0),
align_items: AlignItems::Stretch, align_items: AlignItems::Stretch,
..default() ..default()
}, },
@@ -328,80 +302,6 @@ pub fn spawn_modal_body_text(
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color))); parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
} }
/// Standard empty / loading / error surface (Phase L): a large glyph,
/// a headline, and an optional secondary detail line, centred in the
/// available space. Every async or possibly-empty surface (leaderboard,
/// replays, theme store, first-launch stats) renders through this so
/// "nothing here yet" reads identically everywhere.
///
/// A next-step action is composition, not configuration: spawn a
/// [`spawn_modal_button`] (or any control) in the same parent right
/// after this call.
///
/// `glyph` MUST come from FiraMono-covered ranges — card suits
/// (U+26602666), Arrows (U+219021FF), or ASCII. The Geometric Shapes
/// block renders as tofu on Android (§10 / §11).
pub fn spawn_empty_state(
parent: &mut ChildSpawnerCommands,
glyph: &str,
headline: &str,
detail: Option<&str>,
font_res: Option<&FontResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_glyph = TextFont {
font: font_handle.clone(),
font_size: TYPE_DISPLAY,
..default()
};
let font_headline = TextFont {
font: font_handle.clone(),
font_size: TYPE_BODY_LG,
..default()
};
let font_detail = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
parent
.spawn((
EmptyStateBlock,
Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::Center,
row_gap: VAL_SPACE_2,
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_5),
width: Val::Percent(100.0),
..default()
},
))
.with_children(|block| {
block.spawn((
Text::new(glyph.to_string()),
font_glyph,
TextColor(TEXT_DISABLED),
));
block.spawn((
Text::new(headline.to_string()),
font_headline,
TextColor(TEXT_PRIMARY),
));
if let Some(detail) = detail {
block.spawn((
Text::new(detail.to_string()),
font_detail,
TextColor(TEXT_SECONDARY),
));
}
});
}
/// Marker on every [`spawn_empty_state`] block, mostly for tests.
#[derive(Component, Debug)]
pub struct EmptyStateBlock;
/// Spawns the bottom actions row — flex-row with primary right-aligned. /// Spawns the bottom actions row — flex-row with primary right-aligned.
/// The closure populates the row's buttons via `spawn_modal_button`. /// The closure populates the row's buttons via `spawn_modal_button`.
/// ///
@@ -1258,62 +1158,4 @@ mod tests {
"exactly one of the two stacked dismissible modals should remain" "exactly one of the two stacked dismissible modals should remain"
); );
} }
// -----------------------------------------------------------------------
// Phase L: standard empty state
// -----------------------------------------------------------------------
/// The helper spawns glyph + headline (+ detail when given) under one
/// marked block, so every empty surface renders identically.
#[test]
fn empty_state_spawns_expected_structure() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
{
let world = app.world_mut();
let mut commands = world.commands();
commands.spawn(Node::default()).with_children(|parent| {
spawn_empty_state(
parent,
"\u{2660}",
"Nothing here.",
Some("Do the thing."),
None,
);
spawn_empty_state(parent, "!", "No detail variant.", None, None);
});
}
app.update();
let blocks = app
.world_mut()
.query_filtered::<Entity, With<EmptyStateBlock>>()
.iter(app.world())
.count();
assert_eq!(blocks, 2, "each call spawns exactly one marked block");
let texts: Vec<String> = app
.world_mut()
.query::<&Text>()
.iter(app.world())
.map(|t| t.0.clone())
.collect();
for expected in [
"\u{2660}",
"Nothing here.",
"Do the thing.",
"!",
"No detail variant.",
] {
assert!(
texts.iter().any(|t| t == expected),
"missing text node {expected:?}; got {texts:?}"
);
}
assert_eq!(
texts.len(),
5,
"the detail-less variant must not spawn a detail node"
);
}
} }
-527
View File
@@ -1,527 +0,0 @@
//! One-shot "What's new" card on the first launch after an update
//! (Phase I of the 2026-07 UI redesign).
//!
//! ObtainX updates install silently, so shipped features go unnoticed —
//! nobody found the theme store on their own. On the first launch where
//! the running release differs from `Settings::last_seen_whats_new`,
//! this plugin shows a single dismissible card summarising the latest
//! changelog section, then records the version so the card never
//! repeats. Fresh installs never see it: onboarding completion stamps
//! the current version silently, so the card only ever describes an
//! *upgrade*.
//!
//! # Launch beat
//!
//! Splash → (first run only: onboarding) → **what's new** → Home →
//! table. `spawn_home_on_launch` waits on [`WhatsNewPending`] the same
//! way it waits for the onboarding modal.
//!
//! # Version source
//!
//! The embedded `CHANGELOG.md`'s topmost release section provides both
//! the card's content and the "current version" for change detection —
//! one source of truth, no build-time version plumbing. (The APK
//! `versionName` comes from the release tag at package time and never
//! reaches Rust; the workspace `Cargo.toml` version is static.)
use bevy::input::ButtonInput;
use bevy::prelude::*;
use solitaire_data::save_settings_to;
use crate::font_plugin::FontResource;
use crate::onboarding_plugin::OnboardingScreen;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::ui_modal::{
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
spawn_modal_header,
};
use crate::ui_theme::{
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
};
/// The changelog ships inside the binary — small, changes only at
/// release cadence, and the card must work offline (§4.2).
const CHANGELOG: &str = include_str!("../../CHANGELOG.md");
/// Most bullets shown on the card; a giant release stays skimmable.
const MAX_CARD_BULLETS: usize = 8;
/// Marker on the What's-new modal's scrim root.
#[derive(Component, Debug)]
pub struct WhatsNewScreen;
/// Marker on the card's "Got it" button.
#[derive(Component, Debug)]
struct WhatsNewCloseButton;
/// `true` until this plugin has made its launch-beat decision — either
/// the card was shown and dismissed, or there was nothing to show.
/// `spawn_home_on_launch` waits on this so the card gets the beat
/// before Home.
#[derive(Resource, Debug)]
pub struct WhatsNewPending(pub bool);
impl Default for WhatsNewPending {
fn default() -> Self {
Self(true)
}
}
/// One display line of the card.
#[derive(Debug, PartialEq, Eq)]
enum NoteLine {
/// A `###` section heading ("Added", "Fixed", …).
Heading(String),
/// The lead sentence of one changelog bullet.
Bullet(String),
}
/// The changelog's topmost release section, reduced to card content.
#[derive(Debug)]
struct ReleaseNotes {
/// Version string without the `v` prefix, e.g. `"0.46.0"`.
version: String,
lines: Vec<NoteLine>,
}
/// The version of the topmost changelog release — the engine's notion
/// of "the running release". Empty only if the changelog is malformed.
pub fn current_release_version() -> String {
parse_latest_release(CHANGELOG).map_or_else(String::new, |notes| notes.version)
}
/// Parses the first `## [x.y.z]` section of `changelog` into card
/// content. `## [Unreleased]` is skipped; `### Internal` subsections
/// are dropped (players don't care about CI); each bullet is reduced
/// to its lead sentence with markdown emphasis stripped.
fn parse_latest_release(changelog: &str) -> Option<ReleaseNotes> {
let mut lines_iter = changelog.lines();
let mut version = None;
for line in lines_iter.by_ref() {
if let Some(rest) = line.strip_prefix("## [") {
let (v, _) = rest.split_once(']')?;
if v.eq_ignore_ascii_case("Unreleased") {
continue;
}
version = Some(v.to_string());
break;
}
}
let version = version?;
let mut lines = Vec::new();
let mut skipping_section = false;
let mut current_bullet: Option<String> = None;
let flush = |bullet: &mut Option<String>, lines: &mut Vec<NoteLine>| {
if let Some(text) = bullet.take() {
lines.push(NoteLine::Bullet(lead_sentence(&text)));
}
};
for line in lines_iter {
if line.starts_with("## [") {
break; // next (older) release section
}
if let Some(heading) = line.strip_prefix("### ") {
flush(&mut current_bullet, &mut lines);
skipping_section = heading.trim().eq_ignore_ascii_case("internal");
if !skipping_section {
lines.push(NoteLine::Heading(heading.trim().to_string()));
}
continue;
}
if skipping_section {
continue;
}
if let Some(rest) = line.strip_prefix("- ") {
flush(&mut current_bullet, &mut lines);
current_bullet = Some(rest.trim().to_string());
} else if current_bullet.is_some() && !line.trim().is_empty() {
// Wrapped continuation of the current bullet.
if let Some(bullet) = current_bullet.as_mut() {
bullet.push(' ');
bullet.push_str(line.trim());
}
}
}
flush(&mut current_bullet, &mut lines);
Some(ReleaseNotes { version, lines })
}
/// Strips markdown emphasis / code ticks and truncates to the first
/// sentence — changelog bullets lead with a bold summary sentence, and
/// that is exactly the card-sized version.
fn lead_sentence(bullet: &str) -> String {
let stripped: String = bullet.replace("**", "").replace('`', "");
// Trailing "(#123)" references never survive the sentence cut, but
// guard against a bullet that is only a reference.
let end = stripped.find(". ").map_or(stripped.len(), |i| i + 1);
stripped[..end].trim().trim_end_matches('.').to_string()
}
/// Registers the launch-gate spawn system and the dismiss handlers.
pub struct WhatsNewPlugin;
impl Plugin for WhatsNewPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WhatsNewPending>()
.init_resource::<ButtonInput<KeyCode>>()
.add_systems(
Update,
(maybe_spawn_whats_new, handle_whats_new_close).chain(),
);
}
}
/// Shows the card once the launch surface is clear, or stands down when
/// there is nothing to show. See the module docs for the exact beat.
///
/// The seen-version stamp persists on *spawn*, not dismissal — if the
/// app dies with the card open the player has still seen it once, and
/// the card must never nag.
#[allow(clippy::too_many_arguments)]
fn maybe_spawn_whats_new(
mut commands: Commands,
mut pending: ResMut<WhatsNewPending>,
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
onboarding: Query<(), With<OnboardingScreen>>,
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
other_scrims: Query<(), With<ModalScrim>>,
mut settings: Option<ResMut<SettingsResource>>,
storage_path: Option<Res<SettingsStoragePath>>,
font_res: Option<Res<FontResource>>,
screens: Query<(), With<WhatsNewScreen>>,
) {
if !pending.0 {
return;
}
if !screens.is_empty() {
// Card already open — the seen-version stamp landed on spawn,
// so without this guard the next frame would read "already
// seen" and release the beat under the open card.
return;
}
if !splash.is_empty()
|| !onboarding.is_empty()
|| !restore_prompts.is_empty()
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
{
return;
}
let Some(settings) = settings.as_mut() else {
// Headless / no settings wired: nothing to compare against.
pending.0 = false;
return;
};
if !settings.0.first_run_complete {
// Fresh install mid-onboarding — completion stamps the version
// itself (see `complete_onboarding`), which resolves this gate
// on a later frame without ever showing the card.
return;
}
let Some(notes) = parse_latest_release(CHANGELOG) else {
pending.0 = false;
return;
};
if settings.0.last_seen_whats_new == notes.version {
pending.0 = false;
return;
}
if !other_scrims.is_empty() {
// Another modal owns the beat right now; try again next frame.
return;
}
spawn_whats_new_card(&mut commands, &notes, font_res.as_deref());
settings.0.last_seen_whats_new = notes.version.clone();
if let Some(p) = storage_path
&& let Some(path) = p.0.as_deref()
&& let Err(e) = save_settings_to(path, &settings.0)
{
warn!("whats-new: failed to persist seen version: {e}");
}
// `pending` stays true until dismissal so Home keeps waiting.
}
/// Dismisses the card on "Got it" or Esc and releases the launch beat.
fn handle_whats_new_close(
mut commands: Commands,
keys: Option<Res<ButtonInput<KeyCode>>>,
buttons: Query<&Interaction, (With<WhatsNewCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<WhatsNewScreen>>,
other_scrims: Query<(), (With<ModalScrim>, Without<WhatsNewScreen>)>,
mut pending: ResMut<WhatsNewPending>,
) {
if screens.is_empty() {
return;
}
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)) && other_scrims.is_empty();
if !click && !esc {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
pending.0 = false;
}
/// Spawns the card: header with the version, the parsed changelog
/// lines, and a "Got it" action.
fn spawn_whats_new_card(
commands: &mut Commands,
notes: &ReleaseNotes,
font_res: Option<&FontResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_heading = TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
};
let font_bullet = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
// Deliberately NOT ScrimDismissible: scrim-tap despawns without
// running the close handler, which would leave the launch beat
// held and Home never spawning. Esc and "Got it" both release it.
let title = format!("What's new in v{}", notes.version);
spawn_modal(commands, WhatsNewScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, &title, font_res);
card.spawn(Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_2,
width: Val::Percent(100.0),
max_height: Val::Vh(60.0),
overflow: Overflow::scroll_y(),
..default()
})
.with_children(|body| {
let mut bullets_shown = 0usize;
for line in &notes.lines {
match line {
NoteLine::Heading(heading) => {
body.spawn((
Text::new(heading.clone()),
font_heading.clone(),
TextColor(TEXT_SECONDARY),
Node {
margin: UiRect::top(VAL_SPACE_1),
..default()
},
));
}
NoteLine::Bullet(text) => {
if bullets_shown >= MAX_CARD_BULLETS {
continue;
}
bullets_shown += 1;
body.spawn((
Text::new(format!("\u{2022} {text}")),
font_bullet.clone(),
TextColor(TEXT_PRIMARY),
));
}
}
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
WhatsNewCloseButton,
"Got it",
None,
ButtonVariant::Primary,
font_res,
);
});
});
}
#[cfg(test)]
mod tests {
use super::*;
use solitaire_data::Settings;
const SAMPLE: &str = "# Changelog\n\n## [Unreleased]\n\n## [0.46.0] — 2026-07-13\n\n\
### Added\n\n- **Theme-store previews.** The store modal now shows each theme's\n \
preview image next to its name. (#179)\n- **Hint ghost preview.** You see the move. (#179)\n\n\
### Internal\n\n- **CI is faster.** Nobody cares in-game. (#176)\n\n\
### Fixed\n\n- **Touch onboarding copy.** No more left-click. (#178)\n\n\
## [0.45.0] 2026-07-13\n\n### Added\n\n- Old stuff.\n";
#[test]
fn parses_top_section_version_and_skips_unreleased() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
assert_eq!(notes.version, "0.46.0");
}
#[test]
fn drops_internal_sections_and_older_releases() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
let headings: Vec<&str> = notes
.lines
.iter()
.filter_map(|l| match l {
NoteLine::Heading(h) => Some(h.as_str()),
_ => None,
})
.collect();
assert_eq!(headings, vec!["Added", "Fixed"]);
assert!(
!notes
.lines
.iter()
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("CI is faster"))),
"Internal bullets must not reach the card"
);
assert!(
!notes
.lines
.iter()
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("Old stuff"))),
"older release sections must not bleed in"
);
}
#[test]
fn bullets_reduce_to_their_lead_sentence() {
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
assert!(
notes
.lines
.contains(&NoteLine::Bullet("Theme-store previews".to_string()))
);
assert!(
notes
.lines
.contains(&NoteLine::Bullet("Hint ghost preview".to_string()))
);
}
#[test]
fn embedded_changelog_parses_to_a_nonempty_version() {
let version = current_release_version();
assert!(
!version.is_empty(),
"the real CHANGELOG.md must yield a version"
);
assert!(
version.chars().next().is_some_and(|c| c.is_ascii_digit()),
"version must be bare (no v prefix): {version}"
);
}
fn app_with(settings: Settings) -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(WhatsNewPlugin);
app.insert_resource(SettingsResource(settings));
app.update();
app
}
fn card_count(app: &mut App) -> usize {
app.world_mut()
.query::<&WhatsNewScreen>()
.iter(app.world())
.count()
}
#[test]
fn upgrade_shows_card_and_stamps_version() {
let mut app = app_with(Settings {
first_run_complete: true,
last_seen_whats_new: "0.1.0".into(),
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 1, "an upgrade must show the card");
assert_eq!(
app.world()
.resource::<SettingsResource>()
.0
.last_seen_whats_new,
current_release_version(),
"the seen version must stamp on spawn"
);
assert!(
app.world().resource::<WhatsNewPending>().0,
"the launch beat stays held until dismissal"
);
}
#[test]
fn seen_version_shows_nothing_and_releases_the_beat() {
let mut app = app_with(Settings {
first_run_complete: true,
last_seen_whats_new: current_release_version(),
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 0);
assert!(
!app.world().resource::<WhatsNewPending>().0,
"nothing to show must release the launch beat"
);
}
#[test]
fn fresh_install_waits_for_onboarding_and_never_shows() {
let mut app = app_with(Settings {
first_run_complete: false,
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 0);
assert!(
app.world().resource::<WhatsNewPending>().0,
"mid-onboarding the beat stays held"
);
// Onboarding completion stamps the version (mirrored from
// complete_onboarding) — afterwards the card must stand down.
{
let mut settings = app.world_mut().resource_mut::<SettingsResource>();
settings.0.first_run_complete = true;
settings.0.last_seen_whats_new = current_release_version();
}
app.update();
assert_eq!(card_count(&mut app), 0, "fresh installs never see the card");
assert!(!app.world().resource::<WhatsNewPending>().0);
}
#[test]
fn got_it_dismisses_and_releases_the_beat() {
let mut app = app_with(Settings {
first_run_complete: true,
..Settings::default()
});
app.update();
assert_eq!(card_count(&mut app), 1);
let button = app
.world_mut()
.query_filtered::<Entity, With<WhatsNewCloseButton>>()
.single(app.world())
.expect("Got it button must exist");
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
app.update();
assert_eq!(card_count(&mut app), 0, "Got it must dismiss the card");
assert!(!app.world().resource::<WhatsNewPending>().0);
}
}
+91 -1
View File
@@ -18,7 +18,7 @@
//! shake duration elapses. //! shake duration elapses.
use bevy::prelude::*; use bevy::prelude::*;
use solitaire_core::game_state::GameMode; use solitaire_core::game_state::{GameMode, GameState};
use solitaire_core::scoring::compute_time_bonus; use solitaire_core::scoring::compute_time_bonus;
use solitaire_data::AnimSpeed; use solitaire_data::AnimSpeed;
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
/// score-breakdown reveal can format the mode-multiplier row /// score-breakdown reveal can format the mode-multiplier row
/// (e.g. `Zen ×0.0`, `Classic ×1.0`). /// (e.g. `Zen ×0.0`, `Classic ×1.0`).
pub mode: GameMode, pub mode: GameMode,
/// Per-move-type recap of the winning game, e.g.
/// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`.
/// Built from the upstream session counters at win time; empty when
/// every counter is zero (synthesised test wins).
pub move_detail: String,
}
/// Formats the per-move-type recap line for the win modal from the
/// upstream session counters. Zero-valued components are omitted so the
/// line stays short; returns an empty string when nothing was counted
/// (only possible for synthesised test wins).
fn build_move_detail(game: &GameState) -> String {
let mut parts: Vec<String> = Vec::new();
let foundation = game.move_to_foundation_count();
if foundation > 0 {
parts.push(format!("{foundation} to foundation"));
}
let tableau = game.move_to_tableau_count();
if tableau > 0 {
parts.push(format!("{tableau} tableau moves"));
}
let flips = game.flip_up_count();
if flips > 0 {
parts.push(format!("{flips} flips"));
}
let recycles = game.recycle_count();
if recycles > 0 {
parts.push(format!("{recycles} recycles"));
}
let returns = game.move_from_foundation_count();
if returns > 0 {
parts.push(format!("{returns} foundation returns"));
}
parts.join(" \u{00B7} ")
} }
/// Builds a human-readable XP breakdown string for the win modal. /// Builds a human-readable XP breakdown string for the win modal.
@@ -492,6 +526,7 @@ fn cache_win_data(
pending.challenge_level = challenge_level; pending.challenge_level = challenge_level;
pending.undo_count = game.0.undo_count(); pending.undo_count = game.0.undo_count();
pending.mode = game.0.mode; pending.mode = game.0.mode;
pending.move_detail = build_move_detail(&game.0);
if is_new_record { if is_new_record {
toast.write(InfoToastEvent("New Record!".to_string())); toast.write(InfoToastEvent("New Record!".to_string()));
@@ -913,6 +948,18 @@ fn spawn_overlay(
)); ));
} }
// Move-type recap (same quiet styling as the XP breakdown)
if !pending.move_detail.is_empty() {
card.spawn((
Text::new(pending.move_detail.clone()),
TextFont {
font_size: 15.0,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
// Achievements unlocked this game — at most 3 shown explicitly; // Achievements unlocked this game — at most 3 shown explicitly;
// excess is summarised with "...and N more". // excess is summarised with "...and N more".
if !session.names.is_empty() { if !session.names.is_empty() {
@@ -1288,6 +1335,49 @@ mod tests {
assert_eq!(p.mode, GameMode::Classic); assert_eq!(p.mode, GameMode::Classic);
} }
#[test]
fn build_move_detail_fresh_game_is_empty() {
let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
assert!(
build_move_detail(&game).is_empty(),
"no moves yet, so the recap line must be empty"
);
}
#[test]
fn build_move_detail_reports_played_move_types() {
use solitaire_core::KlondikeInstruction;
// Drive a real deal forward so the upstream counters accumulate:
// prefer foundation moves, then anything else, drawing as needed.
let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
for _ in 0..80 {
let instructions = game.possible_instructions();
let next = instructions
.iter()
.copied()
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
.or_else(|| instructions.into_iter().next());
match next {
Some(i) => {
let _ = game.apply_instruction(i);
}
None => break,
}
if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 {
break;
}
}
let detail = build_move_detail(&game);
assert!(
detail.contains("to foundation"),
"seed 42 reaches a foundation move within 80 plies; got: {detail}"
);
assert!(
!detail.contains("0 "),
"zero-valued components must be omitted; got: {detail}"
);
}
#[test] #[test]
fn build_xp_detail_slow_win_with_undo() { fn build_xp_detail_slow_win_with_undo() {
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus. // 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
@@ -206,9 +206,7 @@ async function main() {
invariant_ok: !!snap?.invariants?.state_ok, invariant_ok: !!snap?.invariants?.state_ok,
history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null, history_len: Array.isArray(snap?.move_history) ? snap.move_history.length : null,
replay_payload_present: payload !== null, replay_payload_present: payload !== null,
replay_moves_len: Array.isArray(payload?.recording?.instructions) replay_moves_len: Array.isArray(payload?.moves) ? payload.moves.length : 0,
? payload.recording.instructions.length
: 0,
}; };
}, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState }); }, { stepCap: maxSteps, policyName: policy, maxVisits: maxVisitsPerState });
@@ -69,9 +69,9 @@ test("draw-mode toggle affects replay payload draw_mode", async ({ page }) => {
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload()); const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
expect(payload.draw_mode).toBe("DrawThree"); expect(payload.draw_mode).toBe("DrawThree");
expect(payload.schema_version).toBe(4); expect(payload.schema_version).toBe(2);
expect(Array.isArray(payload.recording?.instructions)).toBeTruthy(); expect(Array.isArray(payload.moves)).toBeTruthy();
expect(payload.recording.instructions.length).toBeGreaterThan(0); expect(payload.moves.length).toBeGreaterThan(0);
}); });
test("autonomous play keeps invariants stable across seed batch", async ({ page }) => { test("autonomous play keeps invariants stable across seed batch", async ({ page }) => {
+5 -9
View File
@@ -47,7 +47,7 @@ test("debug failure report contains replay diagnostics", async ({ page }) => {
expect(report.invariants).toBeTruthy(); expect(report.invariants).toBeTruthy();
}); });
test("replay payload builder exports a schema-v4 recording", async ({ page }) => { test("replay payload builder exports schema-v2 moves", async ({ page }) => {
await page.goto("/play-classic?seed=42"); await page.goto("/play-classic?seed=42");
await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object"); await page.waitForFunction(() => typeof window.__FERROUS_DEBUG__ === "object");
@@ -57,14 +57,10 @@ test("replay payload builder exports a schema-v4 recording", async ({ page }) =>
.poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null)) .poll(async () => await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload() !== null))
.toBe(true); .toBe(true);
const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload()); const payload = await page.evaluate(() => window.__FERROUS_DEBUG__.replayPayload());
// Schema v4: the wasm layer assembles the whole payload; the deal is expect(payload.schema_version).toBe(2);
// embedded in `recording` and moves live at recording.instructions.
expect(payload.schema_version).toBe(4);
expect(payload.draw_mode).toMatch(/Draw(One|Three)/); expect(payload.draw_mode).toMatch(/Draw(One|Three)/);
expect(payload.mode).toBe("Classic"); expect(payload.mode).toBe("Classic");
expect(payload.recording).toBeTruthy(); expect(Array.isArray(payload.moves)).toBeTruthy();
expect(payload.recording.initial_state).toBeTruthy(); expect(payload.moves.length).toBeGreaterThan(0);
expect(Array.isArray(payload.recording.instructions)).toBeTruthy(); expect(payload.win_move_index).toBe(payload.moves.length - 1);
expect(payload.recording.instructions.length).toBeGreaterThan(0);
expect(payload.win_move_index).toBe(payload.recording.instructions.length - 1);
}); });
+4 -10
View File
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
}, STEP_INTERVAL_MS); }, STEP_INTERVAL_MS);
}); });
/// Step the player back one move. Re-creates the ReplayPlayer and fast- /// Step the player back one move via the wasm-side seek (rewinds to the
/// forwards to (step_idx - 1) without rendering intermediate frames, then /// recorded deal and fast-forwards internally), then renders once so the
/// renders once so the CSS transition animates each card to its previous /// CSS transition animates each card to its previous position.
/// position.
function stepBack() { function stepBack() {
if (!player || player.step_idx() === 0) return; if (!player || player.step_idx() === 0) return;
if (playInterval) { if (playInterval) {
@@ -326,12 +325,7 @@ function stepBack() {
playInterval = null; playInterval = null;
btnPlay.textContent = "▶ Play"; btnPlay.textContent = "▶ Play";
} }
const target = player.step_idx() - 1; render(player.seek(player.step_idx() - 1));
player = new ReplayPlayer(replayJson);
for (let i = 0; i < target; i++) {
player.step();
}
render(player.state());
btnPrev.disabled = player.step_idx() === 0; btnPrev.disabled = player.step_idx() === 0;
btnRestart.disabled = player.step_idx() === 0; btnRestart.disabled = player.step_idx() === 0;
btnStep.disabled = false; btnStep.disabled = false;
+91
View File
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
#[wasm_bindgen] #[wasm_bindgen]
pub struct ReplayPlayer { pub struct ReplayPlayer {
game: GameState, game: GameState,
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
/// can rewind without reparsing the replay JSON.
initial: GameState,
moves: Vec<KlondikeInstruction>, moves: Vec<KlondikeInstruction>,
step_idx: usize, step_idx: usize,
} }
@@ -144,12 +147,30 @@ impl ReplayPlayer {
// the current build maps seeds to deals. // the current build maps seeds to deals.
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode); let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
Ok(Self { Ok(Self {
initial: game.clone(),
game, game,
moves, moves,
step_idx: 0, step_idx: 0,
}) })
} }
/// Jump to `step` (clamped to the move count): the board state after
/// `step` moves have been applied. Rewinds by resetting to the stored
/// initial deal, then fast-forwards — a few hundred instruction
/// applications, microseconds in practice.
pub fn seek_native(&mut self, step: usize) -> Result<StateSnapshot, MoveError> {
let target = step.min(self.moves.len());
if target < self.step_idx {
self.game = self.initial.clone();
self.step_idx = 0;
}
while self.step_idx < target {
self.game.apply_instruction(self.moves[self.step_idx])?;
self.step_idx += 1;
}
Ok(self.snapshot())
}
/// Apply the next move. Returns `Ok(None)` once the list is exhausted. /// Apply the next move. Returns `Ok(None)` once the list is exhausted.
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> { pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
if self.step_idx >= self.moves.len() { if self.step_idx >= self.moves.len() {
@@ -236,6 +257,25 @@ impl ReplayPlayer {
} }
} }
/// Jump directly to `step` moves applied (clamped to the move count)
/// and return the snapshot there. Backwards seeks rewind to the
/// recorded deal and fast-forward, so any position is O(replay length)
/// at worst — no JSON reparse, no intermediate renders.
///
/// Throws `"replay_desync"` if a recorded move is illegal during the
/// fast-forward (corrupt recording).
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
match self.seek_native(step) {
Ok(snap) => {
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
}
Err(e) => {
log_replay_move_error(&e);
Err(JsValue::from_str("replay_desync"))
}
}
}
/// Total number of moves the replay contains. /// Total number of moves the replay contains.
pub fn total_steps(&self) -> usize { pub fn total_steps(&self) -> usize {
self.moves.len() self.moves.len()
@@ -1062,6 +1102,57 @@ mod tests {
assert_eq!(orig["stock"], repl["stock"], "stock deal must match"); assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
} }
/// `seek` must land on exactly the state produced by stepping — both
/// forwards (fast-forward from the current position) and backwards
/// (rewind to the recorded deal, then fast-forward).
#[test]
fn seek_matches_stepping_in_both_directions() {
let mut game = SolitaireGame {
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
};
for _ in 0..24 {
let legal_moves = game.legal_moves_native();
if legal_moves.is_empty() {
break;
}
let idx = pick_move_index(&legal_moves).unwrap_or_default();
game.apply_legal_move_native(idx).expect("advance game");
}
let replay_json = game
.replay_export_native(60, "2026-07-10")
.expect("export replay");
let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A");
let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B");
let total = stepped.total_steps();
assert!(total >= 4, "test needs a few moves, got {total}");
// Forward: step A to k, seek B to k, compare snapshots.
let k = total / 2;
for _ in 0..k {
stepped.step_native().expect("step").expect("mid-replay");
}
let sought = seeker.seek_native(k).expect("seek forward");
assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}");
// Backward: seek B to k - 2 and compare against a fresh stepper.
let back = k - 2;
let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C");
for _ in 0..back {
fresh.step_native().expect("step").expect("mid-replay");
}
let sought_back = seeker.seek_native(back).expect("seek backward");
assert_eq!(
sought_back,
fresh.snapshot(),
"backward seek diverged at {back}"
);
// Clamping: past-the-end seeks stop at the final state.
let end = seeker.seek_native(usize::MAX).expect("seek to end");
assert_eq!(end.step_idx, total);
}
#[test] #[test]
fn debug_api_autonomous_seed_batch_smoke() { fn debug_api_autonomous_seed_batch_smoke() {
for seed in 0_u64..128_u64 { for seed in 0_u64..128_u64 {