Compare commits

..

15 Commits

Author SHA1 Message Date
funman300 ede58f9666 Merge pull request 'fix(engine): restore 60 fps Android card animations + dead-code sweep' (#166) from fix/android-anim-redraw into master
Build and Deploy / build-and-push (push) Successful in 5m52s
Web E2E / web-e2e (push) Successful in 6m42s
Web WASM Rebuild / rebuild (push) Successful in 8m56s
Android Release / build-apk (push) Successful in 5m53s
Test / test (push) Successful in 36m7s
2026-07-09 18:51:06 +00:00
funman300 ccfb9394e0 chore: remove dead code and stale doc claims found in multi-agent sweep
Test / test (pull_request) Successful in 37m58s
Compiler-verified via RUSTFLAGS=--force-warn dead_code plus workspace-wide
reference greps; three parallel audit agents covered the engine crate, the
other eight crates, and Copilot commit-message-vs-diff drift.

Removed:
- replay_overlay/input.rs: 19 orphaned twins (~950 lines) of items also
  defined in mod.rs — the glob re-export made the mod.rs copies win and
  the file-level #![allow(dead_code)] hid the corpses. The live keyboard/
  button handlers and ReplayScrubKeyHold stay; the allow is retired.
- retarget_animation (never called; doc examples were its only refs)
- ScanThemesRequestEvent (never registered/written/read; its doc claimed
  a handle_scan_themes consumer that does not exist)
- _VEC3_REFERENCED workaround const + now-unneeded Vec3 import
- solitaire_data: load_stats/save_stats/time_attack_session_with_now
  default-path wrappers (the _from/_to variants are the live API) and
  surplus re-export names (settings MIN/MAX bounds, token loaders)
- solitaire_core: Session re-export (no external consumer)
- solitaire_wasm: ReplayPlayer::is_finished (no JS caller)
- solitaire_app: build_app wrapper (real entry is run())

Doc fixes:
- audio_plugin: WAV count 5→7, add FoundationCompletedEvent table row,
  drop bogus 'placeholder' label, bevy_kira_audio→kira
- ToastVariant::Warning: variant is live (5 writer plugins); dropped the
  stale allow(dead_code) and its 'currently unused' comment

Deliberately kept: Spider module (staged forward work), WinCascadePlugin
(documented alternative cascade, pending owner decision), SyncCompleteEvent
and solitaire_sync ApiError/merge_at (§8 change-controlled, flagged to owner).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:48:33 -07:00
funman300 b5c1ba4867 fix(engine): emit RequestRedraw from animation systems — restores 60 fps card animation on Android
Commit 38e4c03 (shipped in v0.40.0) switched Android's focused_mode to
reactive_low_power(100 ms) on the premise that every animation tick
system writes RequestRedraw while it has active work. The writers were
never actually added — the diff only contained the WinitSettings change,
imports, and add_message registrations. Result: with no touch input,
nothing wakes the winit loop during a card slide except the 100 ms
fallback ceiling, so animations render at ~10 fps on Android. Desktop
and web keep Continuous mode, which is why only Android was affected
(reported by Rhys the day v0.40.0 shipped; confirmed absent on v0.39.1).

Adds the missing MessageWriter<RequestRedraw> to all seven systems the
original commit message named:
- advance_card_animations (CardAnimationPlugin)
- advance_card_anims (AnimationPlugin — deal/win cascade/slides)
- tick_shake_anim, tick_settle_anim, tick_foundation_flourish
  (FeedbackAnimPlugin)
- drive_toast_display (AnimationPlugin — toast countdown)
- drive_auto_complete (AutoCompletePlugin — step-interval keepalive)

Each writes one RequestRedraw per frame while active work exists
(including delay phases, which also need per-frame ticks). Regression
test asserts an active CardAnimation emits RequestRedraw and an idle
board does not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:25:23 -07:00
Gitea CI d179d9d582 chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 6m46s
Web E2E / web-e2e (push) Successful in 8m27s
2026-07-09 05:53:00 +00:00
funman300 db1cc58f3a Merge pull request 'refactor(core): align Spider with upstream card_game idioms' (#162) from refactor/spider-upstream-idioms into master
Build and Deploy / build-and-push (push) Successful in 6m17s
Web E2E / web-e2e (push) Successful in 6m11s
Web WASM Rebuild / rebuild (push) Successful in 10m7s
Android Release / build-apk (push) Successful in 6m21s
Test / test (push) Successful in 36m4s
2026-07-09 05:26:50 +00:00
funman300 255b781420 Merge pull request 'feat(engine): group HUD menu into Play/You/Community/System + Esc dismissal audit' (#165) from feat/menu-grouping into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m12s
Test / test (push) Successful in 36m2s
2026-07-09 05:10:02 +00:00
funman300 5b5d587818 Merge pull request 'feat(engine): win-summary action hierarchy — Play Again, Watch, Share' (#164) from feat/win-flow into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m22s
Test / test (push) Successful in 36m55s
2026-07-09 05:09:35 +00:00
Gitea CI 2b2e7a7f2c chore(web): regenerate wasm artifacts
Build and Deploy / build-and-push (push) Successful in 5m57s
Web E2E / web-e2e (push) Successful in 9m40s
2026-07-09 05:09:10 +00:00
funman300 5c4d440b31 Merge pull request 'feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal (Phase E)' (#161) from feat/you-hub into master
Web WASM Rebuild / rebuild (push) Has been cancelled
Build and Deploy / build-and-push (push) Successful in 2m18s
Test / test (push) Successful in 38m25s
2026-07-09 05:02:07 +00:00
funman300 38b82d4858 feat(engine): win-summary action hierarchy — Play Again, Watch, Share
Test / test (pull_request) Successful in 36m24s
Phase G of docs/ui-redesign-2026-07.md. The win modal now leads with
actions and reads the stats recap quietly below them:

- Play Again: primary, full-row, Enter accelerator unchanged; still
  fires NewGameRequestEvent::default() (same mode; deal options from
  Settings) so the rematch is one tap.
- Watch Replay / Share Replay: secondary pair reusing the global
  stats_plugin markers (WatchReplayButton / CopyShareLinkButton), so
  both act on the just-won replay — SelectedReplayIndex snaps to 0 on
  every win. This also fixes the old win-modal handler picking
  replays.last(), which after the newest-first history refactor was
  the OLDEST replay, not the newest.
- Watch closes the celebration overlay (new close_overlay_on_watch_-
  replay system); Share keeps it open and relies on the existing
  copy-feedback toasts.
- Stats recap (score breakdown reveal, time, XP, achievements) moves
  below the actions; the Time line drops from headline/primary to
  body/secondary styling.

Tests: 4 new (action presence, Play Again close+request, Watch closes,
Share keeps open) on a manual-clock fixture that steps the 0.5 s
celebration delay deterministically. Workspace suite green, workspace
clippy -D warnings clean, fmt applied.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:37:32 -07:00
funman300 4700bd7912 fix(engine): close Settings, Help, Leaderboard, and theme store on Esc
Test / test (pull_request) Successful in 10m16s
Phase C dismissal audit: Esc / scrim-tap / Done must behave the same
on every modal. Stragglers found and fixed — none of these had any
Esc path (pause's toggle guard swallowed the key while they were
open):

- Settings: Esc clears SettingsScreen, gated on being the topmost
  modal so a stacked sync-setup / theme-store dialog owns Esc
- Help: Esc closes alongside F1/Done (the code comment already
  claimed an Esc path existed — now it does)
- Leaderboard: Esc closes when topmost; the display-name dialog
  stacked above it now Esc-cancels like sync-setup's dialog
- Theme store: Esc closes (always topmost when open)

Scrim-tap opt-ins are unchanged — ui_modal documents which modals
deliberately stay non-dismissible on outside clicks.

Tests: escape_closes_help_screen, escape_closes_settings_screen_flag.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:31:55 -07:00
funman300 d4448bf0cd feat(engine): group HUD menu popover into Play/You/Community/System sections
Phase C of docs/ui-redesign-2026-07.md. The Modes row is gone — Home
owns mode selection, so the popover's Play section carries a Home row
firing the new ToggleHomeRequestEvent (read by toggle_home_screen
alongside the existing M accelerator). Section headers are quiet
caption-size labels inside the existing panel widget, not a new
widget. The action-bar Modes button and its popover are untouched
(their removal is Phase B territory when Home gains hierarchy).

- MenuOption: Modes variant replaced by Home; rows grouped Play (Home)
  · You (Profile, Stats, Achievements) · Community (Leaderboard) ·
  System (Settings, Help)
- handle_menu_option_click no longer chains into spawn_modes_popover
- Tests: tooltip sweep updated (7 rows still), new
  toggle_home_event_opens_home_screen covers the popover's open path

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 20:31:44 -07:00
funman300 1d2b6dc5de refactor(core): align Spider with upstream card_game idioms
Test / test (pull_request) Successful in 37m4s
Addresses upstream author review of the Spider core (PR #157):

- RNG: drop the hand-rolled SplitMix64 + Fisher-Yates; deals now use
  rand::rngs::StdRng seeded via seed_from_u64 + SliceRandom::shuffle,
  exactly like klondike::with_seed. solitaire_core gains the same
  pinned rand dep klondike uses (0.10.1, std_rng only — already in the
  lock, no new transitive deps). Deals for a given seed change; Spider
  has no persisted games yet, so nothing breaks.
- Enums over integers: pile indices and card counts in the instruction
  type are now SpiderTableau (Tableau1..Tableau10, with an ALL const)
  and RunLength (Run1..Run13); out-of-range piles and zero-card moves
  are unrepresentable. Rank comparisons use Rank::checked_add instead
  of u8 arithmetic.
- Fixed-size containers: build_deck returns Stack<104> instead of
  Vec<Card>; possible_instructions is a const-iterated SpiderIter +
  validity filter (klondike's KlondikeIter pattern) instead of
  collecting into a Vec.
- Upstream naming: SpiderGame -> Spider (matches Klondike),
  tableau_face_up/_down -> tableau_face_up_cards/_down_cards,
  stock_len -> stock() accessor, with_rng added alongside with_seed.
- Solver access: SpiderGameState now exposes session(), the same
  escape hatch GameState has — the wrapper no longer pretends to hide
  solve(), which SessionConfig budgets already gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:58:57 -07:00
funman300 9f038250d9 feat(engine): You hub — Profile/Stats/Achievements/Replays in one tabbed modal
Test / test (pull_request) Failing after 8m41s
Phase E of docs/ui-redesign-2026-07.md. New you_hub_plugin owns the
modal shell (header, shared tab chips, single Done); each tab's content
is a body builder extracted from its original plugin with every marker
component unchanged, so per-row update/scroll/selector systems keep
working. The replay selector gets its own Replays tab (Watch/Copy
buttons move into the tab body).

- Toggle*RequestEvents + P/S/A accelerators open the hub on the right
  tab, switch tabs in place, or toggle closed on a same-tab request;
  Esc/Done/scrim-click close
- Legacy ProfileScreen/StatsScreen/AchievementsScreen markers ride the
  hub scrim for the active tab — external queries and tests keep their
  meaning
- Standalone toggle/close systems and per-screen Done buttons removed
  (ProfileCloseButton, StatsCloseButton, AchievementsCloseButton)
- Tests: 2 new hub lifecycle tests; profile/stats/achievements modal
  tests adapted (fixtures add YouHubPlugin; selector tests target the
  Replays tab). Engine suite 916 green, clippy -D warnings, fmt.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 17:10:56 -07:00
funman300 0c69d6859d refactor(engine): extract shared spawn_tab_chip widget into ui_modal
Settings' tab_chip becomes a thin wrapper; the You hub (Phase E) will
reuse the same widget so tabbed modals stay visually identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 16:52:31 -07:00
38 changed files with 1724 additions and 1852 deletions
Generated
+1
View File
@@ -7331,6 +7331,7 @@ dependencies = [
"card_game",
"klondike",
"proptest",
"rand 0.10.1",
"serde",
"thiserror 2.0.18",
]
-5
View File
@@ -37,11 +37,6 @@ fn load_settings() -> Settings {
.unwrap_or_default()
}
/// Build the Bevy app without entering the event loop.
pub fn build_app(sync_provider: Box<dyn SyncProvider + Send + Sync>) -> App {
build_app_with_settings(load_settings(), sync_provider)
}
/// App entry point — configures runtime services, builds, and runs the app.
///
/// Called from both the desktop `bin` target's `main` shim and (on
+5
View File
@@ -16,6 +16,11 @@ serde = { workspace = true }
thiserror = { workspace = true }
klondike = { workspace = true }
card_game = { workspace = true }
# Deliberately NOT the workspace rand (0.9): this pins the exact dep the
# upstream `klondike` crate uses, so `SeedableRng`/`SliceRandom` resolve
# against the same crate version as `klondike::Rng` and Spider deals go
# through the identical shuffle stack as Klondike deals.
rand = { version = "0.10.1", default-features = false, features = ["std_rng"] }
[lints]
workspace = true
+3 -3
View File
@@ -13,7 +13,7 @@ pub mod spider;
// re-exported — they are only used internally (in `klondike_adapter.rs` and
// when decoding instructions to piles in `instruction_to_piles`) and do not
// appear in any public method signature.
pub use card_game::{Card, Deck, Rank, Session, SolveError, Suit};
pub use card_game::{Card, Deck, Rank, SolveError, Suit};
pub use klondike::{
DrawStockConfig, Foundation, Klondike, KlondikeInstruction, KlondikePile, Tableau,
};
@@ -25,8 +25,8 @@ pub use game_state::{DEFAULT_SOLVE_MOVES_BUDGET, DEFAULT_SOLVE_STATES_BUDGET, So
// Spider rules (second `card_game::Game` implementation; engine UI is a
// later phase — nothing outside solitaire_core consumes these yet).
pub use spider::{
SpiderConfig, SpiderGame, SpiderGameState, SpiderInstruction, SpiderScoring, SpiderStats,
SpiderSuits,
RunLength, Spider, SpiderConfig, SpiderGameState, SpiderInstruction, SpiderIter, SpiderMove,
SpiderScoring, SpiderStats, SpiderSuits, SpiderTableau,
};
/// All four foundation slots, in slot order.
+397 -187
View File
@@ -22,11 +22,10 @@
//!
//! ## Determinism
//!
//! Deals are seeded with an inline SplitMix64 + FisherYates shuffle:
//! `solitaire_core` has no `rand` dependency (and adding one needs
//! explicit approval), so Spider's seed space is deliberately
//! self-contained rather than shared with Klondike's `StdRng` seeds.
//! The same seed + suit count always produces the same deal.
//! Deals are seeded exactly like upstream Klondike: [`Rng`] is the same
//! `rand::rngs::StdRng` alias `klondike` exports, seeded through
//! `SeedableRng::seed_from_u64` and applied with a `SliceRandom`
//! shuffle. The same seed + suit count always produces the same deal.
//!
//! ## Card identity caveat (engine integration, later phase)
//!
@@ -43,6 +42,10 @@ use serde::{Deserialize, Serialize};
use crate::error::MoveError;
/// The RNG Spider deals with — the same alias upstream `klondike`
/// exports, so both games share one shuffle stack.
pub type Rng = rand::rngs::StdRng;
/// Number of tableau piles.
pub const SPIDER_TABLEAUS: usize = 10;
/// Total cards in play (two decks).
@@ -146,24 +149,212 @@ impl SpiderStats {
}
// ---------------------------------------------------------------------------
// Instruction
// Piles, run lengths, and instructions
// ---------------------------------------------------------------------------
/// One of the ten Spider tableau piles, in layout order.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderTableau {
/// Leftmost pile (opens with 6 cards).
Tableau1,
/// Second pile (opens with 6 cards).
Tableau2,
/// Third pile (opens with 6 cards).
Tableau3,
/// Fourth pile (opens with 6 cards).
Tableau4,
/// Fifth pile (opens with 5 cards).
Tableau5,
/// Sixth pile (opens with 5 cards).
Tableau6,
/// Seventh pile (opens with 5 cards).
Tableau7,
/// Eighth pile (opens with 5 cards).
Tableau8,
/// Ninth pile (opens with 5 cards).
Tableau9,
/// Rightmost pile (opens with 5 cards).
Tableau10,
}
impl SpiderTableau {
/// All ten piles, in layout order — the canonical iteration source,
/// following `Suit::SUITS` / `Rank::RANKS` upstream.
pub const ALL: [Self; SPIDER_TABLEAUS] = [
Self::Tableau1,
Self::Tableau2,
Self::Tableau3,
Self::Tableau4,
Self::Tableau5,
Self::Tableau6,
Self::Tableau7,
Self::Tableau8,
Self::Tableau9,
Self::Tableau10,
];
const ITER_BEGIN: Self = Self::Tableau1;
const fn next(self) -> Option<Self> {
use SpiderTableau::*;
Some(match self {
Tableau1 => Tableau2,
Tableau2 => Tableau3,
Tableau3 => Tableau4,
Tableau4 => Tableau5,
Tableau5 => Tableau6,
Tableau6 => Tableau7,
Tableau7 => Tableau8,
Tableau8 => Tableau9,
Tableau9 => Tableau10,
Tableau10 => return None,
})
}
/// Zero-based position in layout order.
const fn index(self) -> usize {
self as usize
}
}
/// How many cards a [`SpiderMove`] picks up — at most 13, since a
/// movable run is same-suit strictly-descending.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum RunLength {
/// A single card.
Run1 = 1,
/// The top 2 cards.
Run2 = 2,
/// The top 3 cards.
Run3 = 3,
/// The top 4 cards.
Run4 = 4,
/// The top 5 cards.
Run5 = 5,
/// The top 6 cards.
Run6 = 6,
/// The top 7 cards.
Run7 = 7,
/// The top 8 cards.
Run8 = 8,
/// The top 9 cards.
Run9 = 9,
/// The top 10 cards.
Run10 = 10,
/// The top 11 cards.
Run11 = 11,
/// The top 12 cards.
Run12 = 12,
/// A full K→A run.
Run13 = 13,
}
impl RunLength {
const ITER_BEGIN: Self = Self::Run1;
const fn next(self) -> Option<Self> {
use RunLength::*;
Some(match self {
Run1 => Run2,
Run2 => Run3,
Run3 => Run4,
Run4 => Run5,
Run5 => Run6,
Run6 => Run7,
Run7 => Run8,
Run8 => Run9,
Run9 => Run10,
Run10 => Run11,
Run11 => Run12,
Run12 => Run13,
Run13 => return None,
})
}
/// Number of cards in the run.
pub const fn len(self) -> usize {
self as usize
}
/// True only for [`Self::Run1`]; provided because clippy expects an
/// `is_empty` alongside `len`, and a run is never actually empty.
pub const fn is_empty(self) -> bool {
false
}
}
/// Move the top [`RunLength`] cards (a same-suit descending run) from
/// one pile to another.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderMove {
/// Source pile.
pub from: SpiderTableau,
/// Number of cards picked up from the top of `from`.
pub run: RunLength,
/// Destination pile.
pub to: SpiderTableau,
}
impl SpiderMove {
const ITER_BEGIN: Self = Self {
from: SpiderTableau::ITER_BEGIN,
run: RunLength::ITER_BEGIN,
to: SpiderTableau::ITER_BEGIN,
};
const fn next(self) -> Option<Self> {
let Self { from, run, to } = self;
if let Some(to) = to.next() {
return Some(Self { from, run, to });
}
let to = SpiderTableau::ITER_BEGIN;
if let Some(run) = run.next() {
return Some(Self { from, run, to });
}
let run = RunLength::ITER_BEGIN;
if let Some(from) = from.next() {
return Some(Self { from, run, to });
}
None
}
}
/// One atomic Spider action (the `Game::Instruction` type).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum SpiderInstruction {
/// Move a run between tableau piles.
Move(SpiderMove),
/// Deal one card from the stock onto every tableau pile.
Deal,
/// Move the top `count` face-up cards (a same-suit descending run)
/// from pile `from` to pile `to`. Pile indices are `0..10`.
Move {
/// Source pile index.
from: u8,
/// Destination pile index.
to: u8,
/// Number of cards in the moved run (≥ 1).
count: u8,
},
}
impl SpiderInstruction {
const ITER_BEGIN: Self = Self::Move(SpiderMove::ITER_BEGIN);
const fn next(self) -> Option<Self> {
Some(match self {
Self::Move(spider_move) => match spider_move.next() {
Some(spider_move) => Self::Move(spider_move),
None => Self::Deal,
},
Self::Deal => return None,
})
}
}
/// Exhaustive walk of the Spider instruction space, mirroring
/// `klondike::KlondikeIter`.
pub struct SpiderIter {
instruction: Option<SpiderInstruction>,
}
impl SpiderIter {
const fn new() -> Self {
Self {
instruction: Some(SpiderInstruction::ITER_BEGIN),
}
}
}
impl Iterator for SpiderIter {
type Item = SpiderInstruction;
fn next(&mut self) -> Option<Self::Item> {
let instruction = self.instruction;
self.instruction = instruction?.next();
instruction
}
}
// ---------------------------------------------------------------------------
@@ -174,45 +365,22 @@ pub enum SpiderInstruction {
/// of completed runs. Everything else (undo, score bookkeeping) lives
/// in the wrapping [`Session`].
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct SpiderGame {
pub struct Spider {
tableaus: [Pile<MAX_FACE_DOWN, SPIDER_DECK_SIZE>; SPIDER_TABLEAUS],
stock: Stack<STOCK_SIZE>,
completed_runs: u8,
}
/// SplitMix64 step — small, well-known, and dependency-free. Spider
/// only needs a deterministic shuffle, not cryptographic quality.
const fn splitmix64(state: u64) -> (u64, u64) {
let state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
(state, z ^ (z >> 31))
}
/// In-place FisherYates driven by SplitMix64. The modulo bias is
/// astronomically small for n ≤ 104 and irrelevant for gameplay.
fn shuffle(cards: &mut [Card], seed: u64) {
let mut state = seed;
for i in (1..cards.len()).rev() {
let (next_state, r) = splitmix64(state);
state = next_state;
#[allow(clippy::cast_possible_truncation)]
let j = (r % (i as u64 + 1)) as usize;
cards.swap(i, j);
}
}
/// Builds the 104-card Spider deck for a suit-count difficulty.
///
/// Deck ids spread copies apart where possible (`Deck1..Deck4`), but
/// 1- and 2-suit games necessarily contain identical `Card` values —
/// see the module docs.
fn build_deck(suits: SpiderSuits) -> Vec<Card> {
fn build_deck(suits: SpiderSuits) -> Stack<SPIDER_DECK_SIZE> {
let suit_set = suits.suits();
let copies = SPIDER_DECK_SIZE / (suit_set.len() * RUN_LEN);
let decks = [Deck::Deck1, Deck::Deck2, Deck::Deck3, Deck::Deck4];
let mut cards = Vec::with_capacity(SPIDER_DECK_SIZE);
let mut cards = Stack::new();
for copy in 0..copies {
for &suit in suit_set {
for rank in Rank::RANKS {
@@ -223,15 +391,24 @@ fn build_deck(suits: SpiderSuits) -> Vec<Card> {
cards
}
impl SpiderGame {
impl Spider {
/// Deals a new seeded game at the given suit difficulty.
pub fn with_seed(seed: u64, suits: SpiderSuits) -> Self {
use rand::SeedableRng;
let mut rng = Rng::seed_from_u64(seed);
Self::with_rng(&mut rng, suits)
}
/// Deals a new game from a caller-supplied RNG.
pub fn with_rng(rng: &mut Rng, suits: SpiderSuits) -> Self {
// shuffle a new two-deck spread
let mut deck = build_deck(suits);
shuffle(&mut deck, seed);
use rand::seq::SliceRandom;
deck.shuffle(rng);
let mut cards = deck.into_iter();
let tableaus = core::array::from_fn(|index| {
// Piles 03 open with 5 face-down cards, piles 49 with 4;
// Piles 14 open with 5 face-down cards, piles 510 with 4;
// one face-up card lands on each afterwards.
let down_count = if index < 4 { 5 } else { 4 };
let stack: Stack<MAX_FACE_DOWN> = cards.by_ref().take(down_count).collect();
@@ -250,22 +427,24 @@ impl SpiderGame {
}
}
/// Face-up cards of pile `index` (bottom → top). Empty slice for
/// out-of-range indices.
pub fn tableau_face_up(&self, index: usize) -> &[Card] {
self.tableaus.get(index).map_or(&[], |pile| pile.face_up())
/// Face-up cards of a pile (bottom → top).
pub fn tableau_face_up_cards(&self, tableau: SpiderTableau) -> &[Card] {
self.tableaus[tableau.index()].face_up()
}
/// Face-down cards of pile `index` (bottom → top).
pub fn tableau_face_down(&self, index: usize) -> &[Card] {
self.tableaus
.get(index)
.map_or(&[], |pile| pile.face_down())
/// Face-down cards of a pile (bottom → top).
pub fn tableau_face_down_cards(&self, tableau: SpiderTableau) -> &[Card] {
self.tableaus[tableau.index()].face_down()
}
/// Cards remaining in the stock.
pub fn stock_len(&self) -> usize {
self.stock.len()
/// Topmost face-up card of a pile.
pub fn tableau_top_card(&self, tableau: SpiderTableau) -> Option<&Card> {
self.tableaus[tableau.index()].face_up().last()
}
/// The stock (deals come off the top).
pub const fn stock(&self) -> &Stack<STOCK_SIZE> {
&self.stock
}
/// Completed K→A runs removed from play so far.
@@ -273,19 +452,16 @@ impl SpiderGame {
self.completed_runs
}
/// Length of the longest movable run on top of pile `index`: the
/// maximal same-suit, strictly-descending face-up suffix.
fn movable_run_len(&self, index: usize) -> usize {
let Some(pile) = self.tableaus.get(index) else {
return 0;
};
let up = pile.face_up();
/// Length of the longest movable run on top of a pile: the maximal
/// same-suit, strictly-descending face-up suffix.
fn movable_run_len(&self, tableau: SpiderTableau) -> usize {
let up = self.tableau_face_up_cards(tableau);
let mut len = usize::from(!up.is_empty());
while len < up.len() {
let above = &up[up.len() - len];
let below = &up[up.len() - len - 1];
let descends =
below.suit() == above.suit() && below.rank() as u8 == above.rank() as u8 + 1;
below.suit() == above.suit() && above.rank().checked_add(1) == Some(below.rank());
if !descends {
break;
}
@@ -294,27 +470,27 @@ impl SpiderGame {
len
}
/// Whether `Move { from, to, count }` is legal in this position.
fn is_move_valid(&self, from: u8, to: u8, count: u8) -> bool {
let (from, to, count) = (from as usize, to as usize, count as usize);
if from == to || from >= SPIDER_TABLEAUS || to >= SPIDER_TABLEAUS || count == 0 {
/// Whether a run move is legal in this position.
fn is_move_valid(&self, spider_move: SpiderMove) -> bool {
let SpiderMove { from, run, to } = spider_move;
if from == to {
return false;
}
if count > self.movable_run_len(from) {
if run.len() > self.movable_run_len(from) {
return false;
}
let src_up = self.tableaus[from].face_up();
// Bottom card of the moved run; `count <= movable_run_len <=
// src_up.len()` guarantees the index is in range.
let Some(moved_bottom) = src_up.get(src_up.len() - count) else {
let src_up = self.tableau_face_up_cards(from);
// Bottom card of the moved run; `run.len() <= movable_run_len
// <= src_up.len()` guarantees the index is in range.
let Some(moved_bottom) = src_up.get(src_up.len() - run.len()) else {
return false;
};
match self.tableaus[to].face_up().last() {
match self.tableau_top_card(to) {
// Build down regardless of suit.
Some(dest_top) => dest_top.rank() as u8 == moved_bottom.rank() as u8 + 1,
Some(dest_top) => moved_bottom.rank().checked_add(1) == Some(dest_top.rank()),
// Empty pile accepts anything (face-down remnant can't
// exist without a face-up card — Pile flips eagerly).
None => self.tableaus[to].is_empty(),
None => self.tableaus[to.index()].is_empty(),
}
}
@@ -323,22 +499,24 @@ impl SpiderGame {
!self.stock.is_empty() && self.tableaus.iter().all(|pile| !pile.is_empty())
}
/// Removes a completed K→A same-suit run from the top of pile
/// `index`, if one is present. Returns `true` when a run was
/// removed (and the next face-down card, if any, was flipped).
fn sweep_completed_run(&mut self, index: usize) -> bool {
let Some(pile) = self.tableaus.get_mut(index) else {
return false;
};
/// Removes a completed K→A same-suit run from the top of a pile,
/// if one is present. Returns `true` when a run was removed (and
/// the next face-down card, if any, was flipped).
fn sweep_completed_run(&mut self, tableau: SpiderTableau) -> bool {
let pile = &mut self.tableaus[tableau.index()];
let up = pile.face_up();
if up.len() < RUN_LEN {
return false;
}
let run = &up[up.len() - RUN_LEN..];
let suit = run[0].suit();
let is_complete = run.iter().enumerate().all(|(offset, card)| {
card.suit() == suit && card.rank() as u8 == (RUN_LEN - offset) as u8
});
// The run sits bottom-first (K → A); reversed, it must read
// exactly Ace → King in one suit.
let is_complete = run
.iter()
.rev()
.zip(Rank::RANKS)
.all(|(card, rank)| card.suit() == suit && card.rank() == rank);
if !is_complete {
return false;
}
@@ -348,7 +526,7 @@ impl SpiderGame {
}
}
impl Game for SpiderGame {
impl Game for Spider {
type Score = i32;
type Stats = SpiderStats;
type Config = SpiderConfig;
@@ -365,34 +543,16 @@ impl Game for SpiderGame {
&self,
config: &Self::Config,
) -> impl Iterator<Item = Self::Instruction> + use<> {
let mut out = Vec::new();
if self.is_deal_valid() {
out.push(SpiderInstruction::Deal);
}
for from in 0..SPIDER_TABLEAUS {
let max_run = self.movable_run_len(from);
for count in 1..=max_run {
for to in 0..SPIDER_TABLEAUS {
#[allow(clippy::cast_possible_truncation)]
let instruction = SpiderInstruction::Move {
from: from as u8,
to: to as u8,
count: count as u8,
};
if self.is_move_valid(from as u8, to as u8, count as u8) {
out.push(instruction);
}
}
}
}
let _ = config;
out.into_iter()
let state = self.clone();
let config = config.clone();
SpiderIter::new()
.filter(move |&instruction| state.is_instruction_valid(&config, instruction))
}
fn is_instruction_valid(&self, _config: &Self::Config, instruction: Self::Instruction) -> bool {
match instruction {
SpiderInstruction::Deal => self.is_deal_valid(),
SpiderInstruction::Move { from, to, count } => self.is_move_valid(from, to, count),
SpiderInstruction::Move(spider_move) => self.is_move_valid(spider_move),
}
}
@@ -413,24 +573,24 @@ impl Game for SpiderGame {
match instruction {
SpiderInstruction::Deal => {
stats.deals += 1;
for index in 0..SPIDER_TABLEAUS {
for tableau in SpiderTableau::ALL {
match self.stock.pop() {
Some(card) => self.tableaus[index].push(card),
Some(card) => self.tableaus[tableau.index()].push(card),
None => break,
}
}
for index in 0..SPIDER_TABLEAUS {
if self.sweep_completed_run(index) {
for tableau in SpiderTableau::ALL {
if self.sweep_completed_run(tableau) {
self.completed_runs += 1;
stats.runs_completed += 1;
}
}
}
SpiderInstruction::Move { from, to, count } => {
let (from, to, count) = (from as usize, to as usize, count as usize);
let src_len = self.tableaus[from].face_up().len();
let (cards, _flipped) = self.tableaus[from].take_range_flip_up(src_len - count..);
self.tableaus[to].extend(cards);
SpiderInstruction::Move(SpiderMove { from, run, to }) => {
let src_len = self.tableaus[from.index()].face_up().len();
let (cards, _flipped) =
self.tableaus[from.index()].take_range_flip_up(src_len - run.len()..);
self.tableaus[to.index()].extend(cards);
if self.sweep_completed_run(to) {
self.completed_runs += 1;
stats.runs_completed += 1;
@@ -455,7 +615,7 @@ impl Game for SpiderGame {
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpiderGameState {
seed: u64,
session: Session<SpiderGame>,
session: Session<Spider>,
}
impl SpiderGameState {
@@ -478,7 +638,7 @@ impl SpiderGameState {
};
Self {
seed,
session: Session::new(SpiderGame::with_seed(seed, suits), config),
session: Session::new(Spider::with_seed(seed, suits), config),
}
}
@@ -493,10 +653,18 @@ impl SpiderGameState {
}
/// Current position (read-only).
pub fn game(&self) -> &SpiderGame {
pub fn game(&self) -> &Spider {
self.session.state().state()
}
/// The underlying upstream session, for solver access and replay —
/// the same escape hatch [`crate::game_state::GameState::session`]
/// provides (`session().solve()` honours the budgets configured in
/// [`SessionConfig`]).
pub const fn session(&self) -> &Session<Spider> {
&self.session
}
/// In-play score, clamped at 0 like the Klondike wrapper.
pub fn score(&self) -> i32 {
self.session
@@ -563,7 +731,7 @@ impl SpiderGameState {
// ---------------------------------------------------------------------------
#[cfg(any(test, feature = "test-support"))]
impl SpiderGame {
impl Spider {
/// Builds an arbitrary position for tests: per-pile
/// `(face_down, face_up)` card lists plus stock and completed-run
/// count. No card-count invariants are enforced — stacked
@@ -592,7 +760,7 @@ impl SpiderGame {
#[cfg(any(test, feature = "test-support"))]
impl SpiderGameState {
/// Wraps an arbitrary position in a fresh session (empty history).
pub fn from_test_game(game: SpiderGame, suits: SpiderSuits) -> Self {
pub fn from_test_game(game: Spider, suits: SpiderSuits) -> Self {
let config = SessionConfig {
inner: SpiderConfig {
suits,
@@ -614,6 +782,8 @@ impl SpiderGameState {
#[cfg(test)]
mod tests {
use super::RunLength::*;
use super::SpiderTableau::*;
use super::*;
/// `Card::new` shorthand for stacked positions.
@@ -621,6 +791,11 @@ mod tests {
Card::new(Deck::Deck1, suit, rank)
}
/// `SpiderMove` shorthand for rule tests.
const fn mv(from: SpiderTableau, run: RunLength, to: SpiderTableau) -> SpiderMove {
SpiderMove { from, run, to }
}
/// K→A same-suit run, bottom (King) first — the face-up order a
/// completed run occupies on a pile.
fn full_run(suit: Suit) -> Vec<Card> {
@@ -643,21 +818,25 @@ mod tests {
#[test]
fn opening_deal_shape_is_4x6_6x5_with_50_in_stock() {
let game = SpiderGame::with_seed(42, SpiderSuits::Four);
for index in 0..SPIDER_TABLEAUS {
let game = Spider::with_seed(42, SpiderSuits::Four);
for (index, tableau) in SpiderTableau::ALL.into_iter().enumerate() {
let expected_down = if index < 4 { 5 } else { 4 };
assert_eq!(game.tableau_face_down(index).len(), expected_down);
assert_eq!(game.tableau_face_up(index).len(), 1, "one card face-up");
assert_eq!(game.tableau_face_down_cards(tableau).len(), expected_down);
assert_eq!(
game.tableau_face_up_cards(tableau).len(),
1,
"one card face-up"
);
}
assert_eq!(game.stock_len(), STOCK_SIZE);
assert_eq!(game.stock().len(), STOCK_SIZE);
assert_eq!(game.completed_runs(), 0);
}
#[test]
fn same_seed_same_deal_different_seed_different_deal() {
let a = SpiderGame::with_seed(7, SpiderSuits::Four);
let b = SpiderGame::with_seed(7, SpiderSuits::Four);
let c = SpiderGame::with_seed(8, SpiderSuits::Four);
let a = Spider::with_seed(7, SpiderSuits::Four);
let b = Spider::with_seed(7, SpiderSuits::Four);
let c = Spider::with_seed(8, SpiderSuits::Four);
assert_eq!(a, b);
assert_ne!(a, c);
}
@@ -691,15 +870,21 @@ mod tests {
let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Hearts, Rank::Five)];
layout[1].1 = vec![card(Suit::Spades, Rank::Six)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 1), "5♥ onto 6♠ is legal");
assert!(!game.is_move_valid(1, 0, 1), "6♠ onto 5♥ is not");
let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(
game.is_move_valid(mv(Tableau1, Run1, Tableau2)),
"5♥ onto 6♠ is legal"
);
assert!(
!game.is_move_valid(mv(Tableau2, Run1, Tableau1)),
"6♠ onto 5♥ is not"
);
}
#[test]
fn only_same_suit_runs_are_movable_as_a_group() {
let mut layout = empty_layout();
// Pile 0: 7♠ 6♠ (movable run of 2). Pile 1: 7♥ 6♠ (mixed).
// Pile 1: 7♠ 6♠ (movable run of 2). Pile 2: 7♥ 6♠ (mixed).
layout[0].1 = vec![
card(Suit::Spades, Rank::Seven),
card(Suit::Spades, Rank::Six),
@@ -711,10 +896,19 @@ mod tests {
// Destinations: 8♣ (for the pair), 7♦ (for a lone six).
layout[2].1 = vec![card(Suit::Clubs, Rank::Eight)];
layout[3].1 = vec![card(Suit::Diamonds, Rank::Seven)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 2, 2), "same-suit pair moves");
assert!(!game.is_move_valid(1, 2, 2), "mixed-suit pair does not");
assert!(game.is_move_valid(1, 3, 1), "its top card alone does");
let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(
game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
"same-suit pair moves"
);
assert!(
!game.is_move_valid(mv(Tableau2, Run2, Tableau3)),
"mixed-suit pair does not"
);
assert!(
game.is_move_valid(mv(Tableau2, Run1, Tableau4)),
"its top card alone does"
);
}
#[test]
@@ -724,27 +918,40 @@ mod tests {
card(Suit::Spades, Rank::Nine),
card(Suit::Spades, Rank::Eight),
];
// Pile 1 deliberately left empty.
// Pile 2 deliberately left empty.
layout[2].1 = vec![card(Suit::Hearts, Rank::Nine)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(game.is_move_valid(0, 1, 2), "run onto empty pile");
assert!(game.is_move_valid(2, 1, 1), "single onto empty pile");
let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(
!game.is_move_valid(0, 2, 2),
game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
"run onto empty pile"
);
assert!(
game.is_move_valid(mv(Tableau3, Run1, Tableau2)),
"single onto empty pile"
);
assert!(
!game.is_move_valid(mv(Tableau1, Run2, Tableau3)),
"9♠8♠ cannot land on 9♥ (needs a 10)"
);
}
#[test]
fn invalid_moves_rejected_out_of_range_zero_count_self_move() {
fn invalid_moves_rejected_self_move_and_overlong_run() {
// Out-of-range piles and zero-card runs are unrepresentable in
// `SpiderMove` — the enums only span legal values — so the only
// structurally invalid shapes left are self-moves and runs
// longer than the movable suffix.
let mut layout = empty_layout();
layout[0].1 = vec![card(Suit::Spades, Rank::Five)];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
assert!(!game.is_move_valid(0, 0, 1), "self-move");
assert!(!game.is_move_valid(0, 1, 0), "zero count");
assert!(!game.is_move_valid(0, 10, 1), "destination out of range");
assert!(!game.is_move_valid(10, 0, 1), "source out of range");
assert!(!game.is_move_valid(0, 1, 2), "count exceeds run");
let game = Spider::from_test_layout(layout, Vec::new(), 0);
assert!(
!game.is_move_valid(mv(Tableau1, Run1, Tableau1)),
"self-move"
);
assert!(
!game.is_move_valid(mv(Tableau1, Run2, Tableau2)),
"count exceeds run"
);
}
// -- dealing from stock -------------------------------------------------
@@ -752,31 +959,35 @@ mod tests {
#[test]
fn deal_requires_stock_and_no_empty_pile() {
let stock = vec![card(Suit::Spades, Rank::Ace); 10];
let game = SpiderGame::from_test_layout(junk_layout(), stock.clone(), 0);
let game = Spider::from_test_layout(junk_layout(), stock.clone(), 0);
assert!(game.is_deal_valid());
let mut with_gap = junk_layout();
with_gap[3].1.clear();
let game = SpiderGame::from_test_layout(with_gap, stock, 0);
let game = Spider::from_test_layout(with_gap, stock, 0);
assert!(!game.is_deal_valid(), "empty pile blocks the deal");
let game = SpiderGame::from_test_layout(junk_layout(), Vec::new(), 0);
let game = Spider::from_test_layout(junk_layout(), Vec::new(), 0);
assert!(!game.is_deal_valid(), "empty stock blocks the deal");
}
#[test]
fn deal_puts_one_card_on_every_pile() {
let mut state = SpiderGameState::new_with_suits(3, SpiderSuits::Two);
let before: Vec<usize> = (0..SPIDER_TABLEAUS)
.map(|i| state.game().tableau_face_up(i).len())
let before: Vec<usize> = SpiderTableau::ALL
.into_iter()
.map(|tableau| state.game().tableau_face_up_cards(tableau).len())
.collect();
state
.apply_instruction(SpiderInstruction::Deal)
.expect("deal is legal on a fresh game");
for (index, previous) in before.iter().enumerate() {
assert_eq!(state.game().tableau_face_up(index).len(), previous + 1);
for (tableau, previous) in SpiderTableau::ALL.into_iter().zip(before) {
assert_eq!(
state.game().tableau_face_up_cards(tableau).len(),
previous + 1
);
}
assert_eq!(state.game().stock_len(), STOCK_SIZE - SPIDER_TABLEAUS);
assert_eq!(state.game().stock().len(), STOCK_SIZE - SPIDER_TABLEAUS);
}
#[test]
@@ -787,7 +998,7 @@ mod tests {
.apply_instruction(SpiderInstruction::Deal)
.expect("five deals must all be legal on untouched piles");
}
assert_eq!(state.game().stock_len(), 0);
assert_eq!(state.game().stock().len(), 0);
assert!(matches!(
state.apply_instruction(SpiderInstruction::Deal),
Err(MoveError::RuleViolation(_))
@@ -799,30 +1010,26 @@ mod tests {
#[test]
fn completing_a_run_removes_it_and_flips_the_card_beneath() {
let mut layout = empty_layout();
// Pile 0: one face-down card under K..2 of spades; the ace
// arrives from pile 1.
// Pile 1: one face-down card under K..2 of spades; the ace
// arrives from pile 2.
let mut run = full_run(Suit::Spades);
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0] = (vec![card(Suit::Hearts, Rank::Nine)], run);
layout[1].1 = vec![ace];
let game = SpiderGame::from_test_layout(layout, Vec::new(), 0);
let game = Spider::from_test_layout(layout, Vec::new(), 0);
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
state
.apply_instruction(SpiderInstruction::Move {
from: 1,
to: 0,
count: 1,
})
.apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
.expect("ace onto two completes the run");
assert_eq!(state.game().completed_runs(), 1);
assert_eq!(
state.game().tableau_face_up(0),
state.game().tableau_face_up_cards(Tableau1),
&[card(Suit::Hearts, Rank::Nine)],
"run removed and the buried card flipped face-up"
);
assert!(state.game().tableau_face_up(1).is_empty());
assert!(state.game().tableau_face_up_cards(Tableau2).is_empty());
}
#[test]
@@ -832,16 +1039,12 @@ mod tests {
let ace = run.pop().unwrap_or(card(Suit::Spades, Rank::Ace));
layout[0].1 = run;
layout[1].1 = vec![ace];
let game = SpiderGame::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
let game = Spider::from_test_layout(layout, Vec::new(), TOTAL_RUNS - 1);
let mut state = SpiderGameState::from_test_game(game, SpiderSuits::One);
assert!(!state.is_won());
state
.apply_instruction(SpiderInstruction::Move {
from: 1,
to: 0,
count: 1,
})
.apply_instruction(SpiderInstruction::Move(mv(Tableau2, Run1, Tableau1)))
.expect("winning move is legal");
assert!(state.is_won());
assert!(matches!(
@@ -882,11 +1085,7 @@ mod tests {
#[test]
fn rule_violation_surfaces_move_error() {
let mut state = SpiderGameState::new_with_suits(9, SpiderSuits::One);
let result = state.apply_instruction(SpiderInstruction::Move {
from: 0,
to: 0,
count: 1,
});
let result = state.apply_instruction(SpiderInstruction::Move(mv(Tableau1, Run1, Tableau1)));
assert!(matches!(result, Err(MoveError::RuleViolation(_))));
}
@@ -905,6 +1104,13 @@ mod tests {
assert!(state.game().is_instruction_valid(&config, instruction));
}
}
#[test]
fn instruction_iter_walks_the_full_space_once() {
// 10 sources × 13 run lengths × 10 destinations, plus Deal.
let count = SpiderIter::new().count();
assert_eq!(count, SPIDER_TABLEAUS * RUN_LEN * SPIDER_TABLEAUS + 1);
}
}
#[cfg(test)]
@@ -915,11 +1121,15 @@ mod proptests {
/// Total cards across tableaus + stock + removed runs must always
/// equal 104, and every generated instruction must validate — for
/// any seed, difficulty, and random walk through legal moves.
fn card_conservation(game: &SpiderGame) -> usize {
let on_piles: usize = (0..SPIDER_TABLEAUS)
.map(|i| game.tableau_face_up(i).len() + game.tableau_face_down(i).len())
fn card_conservation(game: &Spider) -> usize {
let on_piles: usize = SpiderTableau::ALL
.into_iter()
.map(|tableau| {
game.tableau_face_up_cards(tableau).len()
+ game.tableau_face_down_cards(tableau).len()
})
.sum();
on_piles + game.stock_len() + usize::from(game.completed_runs()) * RUN_LEN
on_piles + game.stock().len() + usize::from(game.completed_runs()) * RUN_LEN
}
proptest! {
+7 -12
View File
@@ -105,10 +105,9 @@ pub use stats::{StatsExt, StatsSnapshot};
pub mod storage;
pub use storage::{
TimeAttackSession, cleanup_orphaned_tmp_files, delete_game_state_at,
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats,
load_stats_from, load_time_attack_session_from, load_time_attack_session_from_at,
save_game_state_to, save_stats, save_stats_to, save_time_attack_session_to, stats_file_path,
time_attack_session_path, time_attack_session_with_now,
delete_time_attack_session_at, game_state_file_path, load_game_state_from, load_stats_from,
load_time_attack_session_from, save_game_state_to, save_stats_to, save_time_attack_session_to,
stats_file_path, time_attack_session_path,
};
pub mod achievements;
@@ -136,11 +135,9 @@ pub use difficulty_seeds::{DifficultySeeds, seeds_for};
pub mod settings;
pub use settings::{
AnimSpeed, REPLAY_MOVE_INTERVAL_MAX_SECS, REPLAY_MOVE_INTERVAL_MIN_SECS,
REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
TIME_BONUS_MULTIPLIER_MAX, TIME_BONUS_MULTIPLIER_MIN, TIME_BONUS_MULTIPLIER_STEP,
TOOLTIP_DELAY_MAX_SECS, TOOLTIP_DELAY_MIN_SECS, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry,
load_settings_from, save_settings_to, settings_file_path,
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
TIME_BONUS_MULTIPLIER_STEP, TOOLTIP_DELAY_STEP_SECS, Theme, WindowGeometry, load_settings_from,
save_settings_to, settings_file_path,
};
#[cfg(target_os = "android")]
@@ -152,9 +149,7 @@ mod android_keystore;
#[cfg(not(target_arch = "wasm32"))]
pub mod auth_tokens;
#[cfg(not(target_arch = "wasm32"))]
pub use auth_tokens::{
TokenError, delete_tokens, load_access_token, load_refresh_token, store_tokens,
};
pub use auth_tokens::{TokenError, delete_tokens, store_tokens};
pub mod sync_client;
pub use sync_client::LocalOnlyProvider;
-28
View File
@@ -46,22 +46,6 @@ pub fn save_stats_to(path: &Path, stats: &StatsSnapshot) -> io::Result<()> {
Ok(())
}
/// Load stats from the platform default path. Returns default if the path
/// is unavailable or the file is missing/corrupt.
pub fn load_stats() -> StatsSnapshot {
stats_file_path()
.map(|p| load_stats_from(&p))
.unwrap_or_default()
}
/// Save stats to the platform default path. Returns an error if the platform
/// data dir is unavailable or the write fails.
pub fn save_stats(stats: &StatsSnapshot) -> io::Result<()> {
let path = stats_file_path()
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "platform data dir unavailable"))?;
save_stats_to(&path, stats)
}
// ---------------------------------------------------------------------------
// In-progress game state
// ---------------------------------------------------------------------------
@@ -245,18 +229,6 @@ pub fn delete_time_attack_session_at(path: &Path) -> io::Result<()> {
}
}
/// Convenience helper for callers that want to stamp a session with the
/// current wall-clock time. Equivalent to constructing the struct
/// manually and setting `saved_at_unix_secs` to `SystemTime::now()`.
pub fn time_attack_session_with_now(remaining_secs: f32, wins: u32) -> TimeAttackSession {
let now = Utc::now().timestamp().max(0) as u64;
TimeAttackSession {
remaining_secs,
wins,
saved_at_unix_secs: now,
}
}
/// Inner helper: delete `*.tmp` entries inside `dir`.
///
/// Per-file errors (already deleted, permission denied) are silently ignored.
+20 -69
View File
@@ -30,13 +30,9 @@ use crate::replay_playback::ReplayPlaybackState;
use crate::resources::GameStateResource;
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BORDER_SUBTLE, STATE_SUCCESS, TEXT_DISABLED, TEXT_PRIMARY, TEXT_SECONDARY,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, Z_MODAL_PANEL,
TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1,
};
use crate::ui_tooltip::Tooltip;
@@ -137,8 +133,8 @@ impl Plugin for AchievementPlugin {
.after(GameMutation)
.after(StatsUpdate),
)
.add_systems(Update, toggle_achievements_screen)
.add_systems(Update, handle_achievements_close_button)
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_achievements_panel)
.add_systems(
Update,
@@ -385,47 +381,6 @@ pub fn display_name_for(id: &str) -> String {
achievement_by_id(id).map_or_else(|| id.to_string(), |d| d.name.to_string())
}
/// Marker on the "Done" button inside the Achievements modal.
#[derive(Component, Debug)]
pub struct AchievementsCloseButton;
/// Toggle the achievements overlay — `A` keyboard accelerator or
/// `ToggleAchievementsRequestEvent` from the HUD Menu popover.
fn toggle_achievements_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleAchievementsRequestEvent>,
achievements: Res<AchievementsResource>,
font_res: Option<Res<FontResource>>,
screens: Query<Entity, With<AchievementsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<AchievementsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyA) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else if other_modal_scrims.is_empty() {
spawn_achievements_screen(&mut commands, &achievements.0, font_res.as_deref());
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `A` accelerator does.
fn handle_achievements_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<AchievementsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<AchievementsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Routes mouse-wheel events into the Achievements modal's scrollable body
/// while the panel is open.
///
@@ -458,14 +413,18 @@ fn scroll_achievements_panel(
}
}
fn spawn_achievements_screen(
commands: &mut Commands,
/// Builds the Achievements tab body inside the You hub's card. The
/// unlock-count line that used to live in the standalone modal's
/// header renders as the first body line instead (the hub owns the
/// header). All markers (`AchievementRow`, `AchievementsScrollable`)
/// are unchanged.
pub(crate) fn spawn_achievements_body(
card: &mut ChildSpawnerCommands,
records: &[AchievementRecord],
font_res: Option<&FontResource>,
) {
let unlocked: Vec<_> = records.iter().filter(|r| r.unlocked).collect();
let total = ALL_ACHIEVEMENTS.len();
let header = format!("Achievements ({}/{})", unlocked.len(), total);
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_name = TextFont {
@@ -486,8 +445,13 @@ fn spawn_achievements_screen(
let any_unlocked = records.iter().any(|r| r.unlocked);
let scrim = spawn_modal(commands, AchievementsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, header, font_res);
{
// Unlock progress — formerly the standalone modal's header.
card.spawn((
Text::new(format!("Unlocked {} / {}", unlocked.len(), total)),
font_name.clone(),
TextColor(TEXT_SECONDARY),
));
// First-time hint — shown until the player has unlocked anything.
// The list itself describes individual rewards, but a top-level
@@ -594,21 +558,7 @@ fn spawn_achievements_screen(
));
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
AchievementsCloseButton,
"Done",
Some("A"),
ButtonVariant::Primary,
font_res,
);
});
});
// Achievements is a read-only list — clicking the scrim outside
// the card dismisses alongside the existing A / Done paths.
commands.entity(scrim).insert(ScrimDismissible);
}
}
fn format_reward(reward: Reward) -> String {
@@ -668,7 +618,8 @@ mod tests {
.add_plugins(TablePlugin)
.add_plugins(StatsPlugin::headless())
.add_plugins(crate::progress_plugin::ProgressPlugin::headless())
.add_plugins(AchievementPlugin::headless());
.add_plugins(AchievementPlugin::headless())
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
// StatsPlugin's UI toggle system reads ButtonInput<KeyCode>; under
// MinimalPlugins it isn't auto-registered.
app.init_resource::<ButtonInput<KeyCode>>();
+17 -3
View File
@@ -252,10 +252,17 @@ fn advance_card_anims(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut CardAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the winit loop awake at full frame rate while slides (including
// staggered deals still in their delay phase) are in flight — required
// for Android's reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
if anim.delay > 0.0 {
@@ -558,10 +565,17 @@ fn drive_toast_display(
paused: Option<Res<PausedResource>>,
mut queue: ResMut<ToastQueue>,
mut active: ResMut<ActiveToast>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the loop ticking while a toast is displayed or queued so the
// countdown advances and the despawn frame isn't held hostage by
// Android's reactive_low_power wake ceiling.
if active.entity.is_some() || !queue.0.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
// Tick down the active toast timer.
@@ -593,9 +607,9 @@ pub enum ToastVariant {
/// Neutral system message — teal border. Default for `InfoToastEvent`,
/// settings volume notifications, and the auto-complete announcement.
Info,
/// Caution / penalty — gold border. Currently unused by an in-engine
/// event; kept so future warning-flavoured toasts have a slot.
#[allow(dead_code)]
/// Caution / penalty — gold border. Used by [`handle_warning_toast`]
/// for `WarningToastEvent` messages (daily-challenge expiry, sync,
/// theme-store, and leaderboard warnings).
Warning,
/// Failure / rejected action — pink border. Used by
/// [`handle_move_rejected_toast`] for illegal-placement
+5 -4
View File
@@ -1,7 +1,7 @@
//! Sound-effect playback via `kira`.
//!
//! Loads five embedded WAVs (`include_bytes!`) at startup and plays them in
//! response to gameplay events:
//! Loads seven embedded WAVs (`include_bytes!`) at startup — six SFX plus
//! the ambient loop — and plays them in response to gameplay events:
//!
//! | Event | Sound |
//! |---|---|
@@ -10,6 +10,7 @@
//! | `MoveRejectedEvent` | `card_invalid.wav` |
//! | `NewGameRequestEvent` | `card_deal.wav` |
//! | `GameWonEvent` | `win_fanfare.wav` |
//! | `FoundationCompletedEvent` | `foundation_complete.wav` |
//!
//! An ambient loop (`ambient_loop.wav`) is started at plugin startup at very
//! low volume (0.05 amplitude) routed through `music_track`.
@@ -38,7 +39,7 @@ use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
/// Volume amplitude for the stock-recycle draw sound (half of normal 1.0).
const RECYCLE_VOLUME: f64 = 0.5;
/// Volume amplitude for the ambient music loop placeholder.
/// Volume amplitude for the ambient music loop.
const AMBIENT_VOLUME: f64 = 0.05;
/// Converts a linear amplitude (0.01.0+) to the `Decibels` type used by
@@ -101,7 +102,7 @@ pub struct MuteState {
pub music_muted: bool,
}
/// Plays sound effects and background music via `bevy_kira_audio`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
/// Plays sound effects and background music via `kira`. Responds to game events (card place, flip, invalid move, win fanfare) and respects volume settings from `SettingsResource`.
pub struct AudioPlugin;
impl Plugin for AudioPlugin {
@@ -147,6 +147,7 @@ fn drive_auto_complete(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut moves: MessageWriter<MoveRequestEvent>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if !state.active {
return;
@@ -154,6 +155,10 @@ fn drive_auto_complete(
if paused.is_some_and(|p| p.0) {
return;
}
// Keepalive: the step-interval cooldown only advances on frames that
// actually run, so keep the winit loop awake for the whole burst under
// Android's reactive_low_power focused_mode.
redraw.write(RequestRedraw);
state.cooldown -= time.delta_secs();
if state.cooldown > 0.0 {
@@ -18,11 +18,6 @@
//! The sine term is 0 at `t = 0` and `t = 1` and peaks at `t = 0.5`, so the
//! card "floats up" in the middle of its travel and lands at its correct rest z.
//!
//! # Retargeting
//!
//! When a card is redirected mid-flight, call [`retarget_animation`]. It reads
//! the current interpolated position so the card never snaps.
//!
//! # Coexistence with `CardAnim`
//!
//! `CardAnimation` and the legacy `CardAnim` can coexist in the same world but
@@ -33,6 +28,7 @@
use std::f32::consts::PI;
use bevy::prelude::*;
use bevy::window::RequestRedraw;
use super::curves::{MotionCurve, sample_curve};
use super::timing::compute_duration;
@@ -122,8 +118,6 @@ impl CardAnimation {
}
/// Returns the current interpolated XY position without advancing time.
///
/// Used by [`retarget_animation`] to read mid-flight position cleanly.
pub fn current_xy(&self) -> Vec2 {
if self.duration <= 0.0 {
return self.end;
@@ -134,90 +128,6 @@ impl CardAnimation {
}
}
// ---------------------------------------------------------------------------
// Retarget helper
// ---------------------------------------------------------------------------
/// Redirects a card to a new destination without snapping or interrupting motion.
///
/// Reads the card's current interpolated position (from a live [`CardAnimation`]
/// if present, or from `Transform` if stationary) and starts a fresh
/// [`CardAnimation`] from that position. Duration is recalculated from the
/// remaining distance so short paths stay quick.
///
/// # Velocity continuity
///
/// When a card is mid-flight, the new animation starts with a small positive
/// `elapsed` offset (`carry`) derived from how far through the current animation
/// the card is. This preserves a sense of forward momentum: the new curve does
/// not restart from zero velocity, avoiding a visible "lurch" when the target
/// changes rapidly.
///
/// The carry is deliberately small (≤ 10 % of the new duration) so that it
/// never causes a visible position jump — the card's start position is still
/// read from the current transform.
///
/// # Example
///
/// ```ignore
/// // Inside a system that decides to move a card to a new target:
/// let (entity, transform, anim) = cards.get(card_entity)?;
/// retarget_animation(
/// &mut commands,
/// entity,
/// anim, // Option<&CardAnimation>
/// transform,
/// Vec2::new(400.0, 200.0),
/// resting_z,
/// MotionCurve::SmoothSnap,
/// );
/// ```
pub fn retarget_animation(
commands: &mut Commands,
entity: Entity,
current_anim: Option<&CardAnimation>,
transform: &Transform,
new_end: Vec2,
new_end_z: f32,
curve: MotionCurve,
) {
let (current_xy, current_z, momentum_carry) = match current_anim {
Some(anim) if anim.duration > 0.0 => {
// Estimate how far into the current animation we are and carry
// a small fraction of that progress into the new animation.
// This avoids restarting from zero velocity and makes the motion
// feel continuous when the target changes mid-flight.
let t = (anim.elapsed / anim.duration).clamp(0.0, 1.0);
// Cap at 10 % of the new animation so there's no visible jump.
let carry = (t * 0.12).min(0.10);
(anim.current_xy(), transform.translation.z, carry)
}
_ => (
transform.translation.truncate(),
transform.translation.z,
0.0,
),
};
let distance = current_xy.distance(new_end);
let duration = compute_duration(distance);
commands.entity(entity).insert(CardAnimation {
start: current_xy,
end: new_end,
// Start slightly into the new animation to carry forward momentum.
elapsed: momentum_carry * duration,
duration,
curve,
delay: 0.0,
start_z: current_z,
end_z: new_end_z,
z_lift: 8.0,
scale_start: 1.0,
scale_end: 1.0,
});
}
// ---------------------------------------------------------------------------
// System
// ---------------------------------------------------------------------------
@@ -232,10 +142,18 @@ pub(crate) fn advance_card_animations(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut q: Query<(Entity, &mut Transform, &mut CardAnimation)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Keep the winit event loop awake while any animation (including one
// still in its delay phase) needs per-frame ticks. Without this,
// Android's reactive_low_power focused_mode only wakes at its 100 ms
// ceiling and card slides render at ~10 fps.
if !q.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut q {
+44 -23
View File
@@ -31,28 +31,6 @@
//! ));
//! ```
//!
//! Retarget a card mid-flight:
//!
//! ```ignore
//! use solitaire_engine::card_animation::retarget_animation;
//!
//! fn handle_drop(
//! mut commands: Commands,
//! q: Query<(Entity, &Transform, Option<&CardAnimation>), With<CardEntity>>,
//! ) {
//! let (entity, transform, anim) = q.get(card_entity).unwrap();
//! retarget_animation(
//! &mut commands,
//! entity,
//! anim,
//! transform,
//! new_target_xy,
//! new_target_z,
//! MotionCurve::SmoothSnap,
//! );
//! }
//! ```
//!
//! # Win cascade with `Expressive` curve
//!
//! The existing `AnimationPlugin` drives the win cascade with `CardAnim`
@@ -80,7 +58,7 @@ pub mod interaction;
pub mod timing;
pub mod tuning;
pub use animation::{CardAnimation, retarget_animation, win_scatter_targets};
pub use animation::{CardAnimation, win_scatter_targets};
pub use chain::AnimationChain;
pub use curves::{MotionCurve, sample_curve};
pub use diagnostics::{FrameTimeDiagnostics, WINDOW_SIZE as DIAG_WINDOW_SIZE};
@@ -307,6 +285,49 @@ mod tests {
);
}
/// Regression test for the v0.40.0 Android animation-lag bug: commit
/// 38e4c03 switched Android to `reactive_low_power` focused_mode on the
/// premise that animation systems write `RequestRedraw` while active,
/// but the writers were never added — card slides rendered at the 100 ms
/// wake ceiling (~10 fps). Active animations MUST emit `RequestRedraw`
/// every frame; an idle board must not.
#[test]
fn active_card_animation_requests_redraw() {
let mut app = App::new();
app.add_plugins(MinimalPlugins)
.add_plugins(CardAnimationPlugin);
// Idle board: no redraw requests.
app.update();
assert!(
app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"no RequestRedraw expected while no animation is active"
);
app.world_mut().spawn((
Transform::from_translation(Vec3::ZERO),
CardAnimation {
start: Vec2::ZERO,
end: Vec2::new(100.0, 0.0),
elapsed: 0.0,
duration: 1.0,
curve: MotionCurve::Responsive,
delay: 0.0,
start_z: 0.0,
end_z: 0.0,
z_lift: 0.0,
scale_start: 1.0,
scale_end: 1.0,
},
));
app.update();
assert!(
!app.world().resource::<Messages<RequestRedraw>>().is_empty(),
"an active CardAnimation must write RequestRedraw each frame to \
sustain the reactive render loop"
);
}
#[test]
fn card_animation_instant_snaps_on_zero_duration() {
let mut app = App::new();
+1
View File
@@ -111,6 +111,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(HelpPlugin)
.add_plugins(HomePlugin::default())
.add_plugins(ProfilePlugin)
.add_plugins(crate::you_hub_plugin::YouHubPlugin)
.add_plugins(PausePlugin)
.add_plugins(SettingsPlugin::default())
.add_plugins(OnboardingPlugin)
+5 -9
View File
@@ -243,6 +243,11 @@ pub struct ToggleSettingsRequestEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleLeaderboardRequestEvent;
/// Request to toggle the Home mode launcher. Fired by the HUD
/// Menu-popover "Home" row alongside the existing `M` accelerator.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ToggleHomeRequestEvent;
/// Fired by `SyncPlugin` after a pull task resolves and the merged result has
/// been persisted to disk. `Ok(SyncResponse)` carries the merged payload plus
/// any `ConflictReport`s the merge produced. `Err(String)` carries a
@@ -295,15 +300,6 @@ pub struct ForfeitEvent;
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ForfeitRequestEvent;
/// Fired when the player clicks "Scan for new themes" in Settings.
///
/// Consumed by `handle_scan_themes` in `SettingsPlugin`, which scans
/// `user_theme_dir()` for `.zip` files, calls `import_theme()` on each
/// unrecognised archive, refreshes [`crate::theme::ThemeRegistry`], and
/// fires [`InfoToastEvent`] messages to report results.
#[derive(Message, Debug, Clone, Copy, Default)]
pub struct ScanThemesRequestEvent;
/// Fired when the player requests a hint (H key). Carries the source card ID
/// and destination pile for visual highlighting.
///
@@ -276,10 +276,16 @@ fn tick_shake_anim(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut ShakeAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the shake under Android's
// reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt;
@@ -356,10 +362,16 @@ fn tick_settle_anim(
time: Res<Time>,
paused: Option<Res<PausedResource>>,
mut anims: Query<(Entity, &mut Transform, &mut SettleAnim)>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the settle bounce under Android's
// reactive_low_power focused_mode.
if !anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
for (entity, mut transform, mut anim) in &mut anims {
anim.elapsed += dt;
@@ -581,10 +593,16 @@ fn tick_foundation_flourish(
(Entity, &mut Sprite, &mut FoundationMarkerFlourish),
Without<FoundationFlourish>,
>,
mut redraw: MessageWriter<RequestRedraw>,
) {
if paused.is_some_and(|p| p.0) {
return;
}
// Sustain full-rate frames for the flourish under Android's
// reactive_low_power focused_mode.
if !card_anims.is_empty() || !marker_anims.is_empty() {
redraw.write(RequestRedraw);
}
let dt = time.delta_secs();
// Advance the King's scale pulse.
+36 -2
View File
@@ -86,13 +86,18 @@ fn toggle_help_screen(
}
/// Click handler for the modal's "Done" button. F1 toggles the overlay
/// the same way; this just exposes the close action to mouse / touch.
/// the same way; Esc closes too, so dismissal matches every other
/// modal (Phase C dismissal audit). Nothing ever stacks above Help,
/// so Esc needs no topmost gate.
fn handle_help_close_button(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<HelpCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<HelpScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return;
}
for entity in &screens {
@@ -583,4 +588,33 @@ mod tests {
0
);
}
/// Esc must dismiss the Help modal like Done and F1 do (Phase C
/// dismissal audit).
#[test]
fn escape_closes_help_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::F1);
app.update();
{
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
input.release(KeyCode::F1);
input.clear();
input.press(KeyCode::Escape);
}
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&HelpScreen>()
.iter(app.world())
.count(),
0,
"Esc must close the Help modal"
);
}
}
+44 -2
View File
@@ -24,7 +24,8 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{
InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
StartDailyChallengeRequestEvent, StartDifficultyRequestEvent, StartPlayBySeedRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleProfileRequestEvent,
StartTimeAttackRequestEvent, StartZenRequestEvent, ToggleHomeRequestEvent,
ToggleProfileRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
@@ -264,6 +265,7 @@ impl Plugin for HomePlugin {
.add_message::<StartPlayBySeedRequestEvent>()
.add_message::<StartDifficultyRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<SettingsChangedEvent>()
// Defensively register MouseWheel so `scroll_home_panel`
@@ -371,6 +373,7 @@ fn spawn_home_on_launch(
fn toggle_home_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleHomeRequestEvent>,
progress: Option<Res<ProgressResource>>,
stats: Option<Res<StatsResource>>,
settings: Option<Res<SettingsResource>>,
@@ -380,7 +383,8 @@ fn toggle_home_screen(
other_modal_scrims: Query<(), (With<crate::ui_modal::ModalScrim>, Without<HomeScreen>)>,
diff_expanded: Res<DifficultyExpanded>,
) {
if !keys.just_pressed(KeyCode::KeyM) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyM) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
@@ -1621,6 +1625,44 @@ mod tests {
);
}
/// The HUD Menu popover's "Home" row fires
/// `ToggleHomeRequestEvent`; it must open Home exactly like the
/// `M` accelerator (Phase C: the popover's Play section replaces
/// the old Modes row).
#[test]
fn toggle_home_event_opens_home_screen() {
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
.write(ToggleHomeRequestEvent);
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
1,
"ToggleHomeRequestEvent must open the Home modal"
);
// A second request toggles it closed, matching the M key.
app.world_mut()
.resource_mut::<Messages<ToggleHomeRequestEvent>>()
.write(ToggleHomeRequestEvent);
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&HomeScreen>()
.iter(app.world())
.count(),
0,
"second ToggleHomeRequestEvent must close the Home modal"
);
}
#[test]
fn pressing_m_twice_closes_home_screen() {
let mut app = headless_app();
+95 -64
View File
@@ -311,44 +311,65 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
..default()
};
// Each row carries a tooltip alongside its label so hover reveals
// a one-line description of what each overlay shows — mirroring
// the tooltips on the action-bar buttons that opened this popover.
let rows: [(MenuOption, &'static str, &'static str); 7] = [
// One popover row: destination, label, hover tooltip.
type MenuRow = (MenuOption, &'static str, &'static str);
// Destinations grouped into labelled sections (Phase C of the menu
// redesign): Play · You · Community · System. Each row carries a
// tooltip alongside its label so hover reveals a one-line
// description of what each overlay shows — mirroring the tooltips
// on the action-bar buttons that opened this popover. Mode
// selection lives on Home now, so there is no Modes row.
let sections: [(&'static str, &'static [MenuRow]); 4] = [
(
MenuOption::Help,
"Help",
"Show controls, rules, and keyboard shortcuts.",
"Play",
&[(
MenuOption::Home,
"Home",
"Pick a mode, continue, or start a new game.",
)],
),
(
MenuOption::Modes,
"Game Modes",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"You",
&[
(
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
),
(
MenuOption::Stats,
"Stats",
"Lifetime totals: wins, streaks, fastest time, best score.",
),
(
MenuOption::Achievements,
"Achievements",
"Browse unlocked achievements and the rewards still ahead.",
),
],
),
(
MenuOption::Stats,
"Stats",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Community",
&[(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
)],
),
(
MenuOption::Achievements,
"Achievements",
"Browse unlocked achievements and the rewards still ahead.",
),
(
MenuOption::Profile,
"Profile",
"Your level, XP progress, and sync status.",
),
(
MenuOption::Settings,
"Settings",
"Audio, animations, theme, draw mode, and sync.",
),
(
MenuOption::Leaderboard,
"Leaderboard",
"Top players from your sync server. Opt in from Profile.",
"System",
&[
(
MenuOption::Settings,
"Settings",
"Audio, animations, theme, draw mode, and sync.",
),
(
MenuOption::Help,
"Help",
"Show controls, rules, and keyboard shortcuts.",
),
],
),
];
@@ -373,27 +394,48 @@ pub(super) fn spawn_menu_popover(commands: &mut Commands, font_res: Option<&Font
ZIndex(Z_HUD_POPOVER),
))
.with_children(|panel| {
for (option, label, tooltip) in rows {
let section_font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
for (section, rows) in sections {
// Non-interactive section header — a quiet divider
// inside the existing panel, not a new widget.
panel
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.spawn(Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(2.0)),
..default()
})
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
b.spawn((
Text::new(section),
section_font.clone(),
TextColor(TEXT_SECONDARY),
));
});
for &(option, label, tooltip) in rows {
panel
.spawn((
option,
ActionButton,
PopoverRow,
Button,
Tooltip::new(tooltip),
Node {
padding: UiRect::axes(VAL_SPACE_3, Val::Px(6.0)),
justify_content: JustifyContent::FlexStart,
align_items: AlignItems::Center,
min_width: Val::Px(150.0),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(ACTION_BTN_IDLE),
))
.with_children(|b| {
b.spawn((Text::new(label), font.clone(), TextColor(TEXT_PRIMARY)));
});
}
}
});
@@ -422,31 +464,28 @@ pub(super) fn handle_menu_option_click(
interaction_query: Query<(&Interaction, &MenuOption), Changed<Interaction>>,
popovers: Query<Entity, With<MenuPopover>>,
backdrops: Query<Entity, With<MenuPopoverBackdrop>>,
mut home: MessageWriter<ToggleHomeRequestEvent>,
mut stats: MessageWriter<ToggleStatsRequestEvent>,
mut achievements: MessageWriter<ToggleAchievementsRequestEvent>,
mut profile: MessageWriter<ToggleProfileRequestEvent>,
mut settings: MessageWriter<ToggleSettingsRequestEvent>,
mut leaderboard: MessageWriter<ToggleLeaderboardRequestEvent>,
mut help: MessageWriter<HelpRequestEvent>,
progress: Option<Res<ProgressResource>>,
daily: Option<Res<DailyChallengeResource>>,
font_res: Option<Res<FontResource>>,
mut commands: Commands,
) {
let mut clicked_any = false;
let mut open_modes = false;
for (interaction, option) in &interaction_query {
if *interaction != Interaction::Pressed {
continue;
}
clicked_any = true;
match option {
MenuOption::Home => {
home.write(ToggleHomeRequestEvent);
}
MenuOption::Help => {
help.write(HelpRequestEvent);
}
MenuOption::Modes => {
open_modes = true;
}
MenuOption::Stats => {
stats.write(ToggleStatsRequestEvent);
}
@@ -470,14 +509,6 @@ pub(super) fn handle_menu_option_click(
commands.entity(e).despawn();
}
}
if open_modes {
spawn_modes_popover(
&mut commands,
progress.as_deref(),
daily.as_deref(),
font_res.as_deref(),
);
}
}
/// Despawns the [`ModesPopover`] and its backdrop when Escape / Android back
+14 -10
View File
@@ -30,9 +30,9 @@ use crate::daily_challenge_plugin::DailyChallengeResource;
use crate::events::{
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleLeaderboardRequestEvent,
ToggleProfileRequestEvent, ToggleSettingsRequestEvent, ToggleStatsRequestEvent,
UndoRequestEvent, WinStreakMilestoneEvent,
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
ToggleStatsRequestEvent, UndoRequestEvent, WinStreakMilestoneEvent,
};
use crate::font_plugin::FontResource;
use crate::game_plugin::{GameMutation, NewGameRequestWriters};
@@ -386,8 +386,9 @@ pub enum ModeOption {
}
/// Marker on the "Menu" action button. Click toggles the [`MenuPopover`]
/// which exposes the Stats / Achievements / Profile / Settings /
/// Leaderboard overlays without needing the S/A/P/O/L hotkeys.
/// which exposes the Home / Profile / Stats / Achievements /
/// Leaderboard / Settings / Help overlays without needing the
/// M/P/S/A/L/O/F1 hotkeys.
#[derive(Component, Debug)]
pub struct MenuButton;
@@ -413,16 +414,18 @@ struct MenuPopoverBackdrop;
struct ModesPopoverBackdrop;
/// One row inside the [`MenuPopover`]. The variant selects which
/// `Toggle*RequestEvent` the click handler fires.
/// `Toggle*RequestEvent` the click handler fires. Rows render grouped
/// under section headers (Play · You · Community · System); mode
/// selection lives on Home, so there is no Modes row here.
#[derive(Component, Debug, Clone, Copy)]
pub enum MenuOption {
Help,
Modes,
Home,
Profile,
Stats,
Achievements,
Profile,
Settings,
Leaderboard,
Settings,
Help,
}
/// HUD Z-layer — above cards (which start at z=0) but below overlay screens.
@@ -459,6 +462,7 @@ impl Plugin for HudPlugin {
.add_message::<StartTimeAttackRequestEvent>()
.add_message::<StartDailyChallengeRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleHomeRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleSettingsRequestEvent>()
+1 -1
View File
@@ -786,8 +786,8 @@ fn popover_rows_carry_tooltip_strings() {
menu_tooltips.len()
);
for expected in [
"Pick a mode, continue, or start a new game.",
"Show controls, rules, and keyboard shortcuts.",
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
"Lifetime totals: wins, streaks, fastest time, best score.",
"Browse unlocked achievements and the rewards still ahead.",
"Your level, XP progress, and sync status.",
+1 -6
View File
@@ -22,7 +22,7 @@ use std::collections::HashMap;
use bevy::ecs::system::SystemParam;
use bevy::input::ButtonInput;
use bevy::input::touch::{TouchInput, TouchPhase, Touches};
use bevy::math::{Vec2, Vec3};
use bevy::math::Vec2;
use bevy::prelude::*;
use bevy::window::PrimaryWindow;
#[cfg(not(target_os = "android"))]
@@ -1806,10 +1806,5 @@ pub fn find_hint(game: &GameState) -> Option<(KlondikePile, KlondikePile)> {
all_hints(game).into_iter().next()
}
// `Vec3` is referenced only via the `DRAG_Z` constant; keep the import silenced
// when the compiler can't see it used.
#[allow(dead_code)]
const _VEC3_REFERENCED: Option<Vec3> = None;
#[cfg(test)]
mod tests;
+16 -2
View File
@@ -343,13 +343,21 @@ fn scroll_leaderboard_panel(
}
}
/// Done click or Esc dismisses the leaderboard (Phase C dismissal
/// audit). Esc only fires when the leaderboard is the topmost modal —
/// with the display-name dialog stacked on top, that dialog owns Esc.
fn handle_leaderboard_close_button(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
close_buttons: Query<&Interaction, (With<LeaderboardCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
mut closed_flag: ResMut<ClosedThisFrame>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
let clicked = close_buttons.iter().any(|i| *i == Interaction::Pressed);
let esc =
keys.just_pressed(KeyCode::Escape) && !screens.is_empty() && other_modal_scrims.is_empty();
if !clicked && !esc {
return;
}
for entity in &screens {
@@ -888,12 +896,18 @@ fn handle_display_name_confirm(
}
/// Discards any typed text and closes the display-name editor modal.
/// Cancel click or Esc dismisses the display-name dialog without
/// saving (Phase C dismissal audit — same contract as the sync-setup
/// dialog's Cancel/Esc pair).
fn handle_display_name_cancel(
button_q: Query<&Interaction, (Changed<Interaction>, With<DisplayNameCancelButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<DisplayNameModal>>,
mut commands: Commands,
) {
if !button_q.iter().any(|i| *i == Interaction::Pressed) {
let clicked = button_q.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return;
}
for entity in &screens {
+4 -2
View File
@@ -65,6 +65,7 @@ pub mod ui_theme;
pub mod ui_tooltip;
pub mod weekly_goals_plugin;
pub mod win_summary_plugin;
pub mod you_hub_plugin;
pub use achievement_plugin::{AchievementPlugin, AchievementsResource, AchievementsScreen};
#[cfg(not(target_arch = "wasm32"))]
@@ -83,8 +84,8 @@ pub use card_animation::{
AnimationChain, AnimationTuning, BufferedInput, CardAnimation, CardAnimationPlugin,
DEAL_INTERVAL_SECS, DIAG_WINDOW_SIZE, FrameTimeDiagnostics, HoverState, InputBuffer,
InputPlatform, MAX_DURATION_SECS, MIN_DURATION_SECS, MotionCurve, WIN_CASCADE_INTERVAL_SECS,
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, retarget_animation,
sample_curve, win_scatter_targets,
WinCascadePlugin, cascade_delay, compute_duration, micro_vary, sample_curve,
win_scatter_targets,
};
pub use card_plugin::{
CardEntity, CardImageSet, CardLabel, CardPlugin, HintHighlight, HintHighlightTimer,
@@ -197,3 +198,4 @@ pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
pub use win_summary_plugin::{
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
};
pub use you_hub_plugin::{ActiveYouTab, YouHubPlugin, YouHubScreen, YouTab};
+19 -102
View File
@@ -4,7 +4,6 @@
//! summary in a single scrollable panel. Spawned on the first `P` keypress and
//! despawned on the second.
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use chrono::{Duration, Local, NaiveDate};
@@ -13,23 +12,19 @@ use solitaire_data::SyncBackend;
use crate::achievement_plugin::AchievementsResource;
#[cfg(not(target_arch = "wasm32"))]
use crate::avatar_plugin::AvatarResource;
pub(crate) use crate::avatar_plugin::AvatarResource;
#[cfg(target_arch = "wasm32")]
#[derive(bevy::prelude::Resource)]
struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
pub(crate) struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
use crate::events::ToggleProfileRequestEvent;
use crate::font_plugin::FontResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::{SyncStatus, SyncStatusResource};
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, format_fastest_win, format_win_rate};
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_theme::{
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, SPACE_1, STATE_INFO, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2,
};
/// Number of days surfaced in the daily-challenge calendar row.
@@ -60,13 +55,11 @@ pub struct DailyCalendarDot {
pub is_today: bool,
}
/// Registers the `P` key toggle for the profile overlay.
/// Registers the Profile body's scroll handling. Opening/closing moved
/// to the You hub (`you_hub_plugin`), which calls
/// [`spawn_profile_body`] for this tab's content.
pub struct ProfilePlugin;
/// Marker on the "Done" button inside the Profile modal.
#[derive(Component, Debug)]
pub struct ProfileCloseButton;
/// Marker on the scrollable body Node inside the Profile modal.
///
/// The Profile panel renders sync info, progression (incl. 14-day
@@ -87,14 +80,9 @@ impl Plugin for ProfilePlugin {
// profile-scroll system also runs cleanly under
// `MinimalPlugins` in tests.
.add_message::<MouseWheel>()
.add_systems(
Update,
(
toggle_profile_screen,
handle_profile_close_button,
scroll_profile_panel,
),
);
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content + scroll.
.add_systems(Update, scroll_profile_panel);
}
}
@@ -124,70 +112,12 @@ fn scroll_profile_panel(
}
}
fn handle_profile_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<ProfileCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<ProfileScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
/// Builds the Profile tab body inside the You hub's card. All markers
/// (`ProfileScrollable`, `DailyCalendarDot`, …) are unchanged, so the
/// scroll and test queries keep working.
#[allow(clippy::too_many_arguments)]
fn toggle_profile_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleProfileRequestEvent>,
settings: Option<Res<SettingsResource>>,
sync_status: Option<Res<SyncStatusResource>>,
progress: Option<Res<ProgressResource>>,
achievements: Option<Res<AchievementsResource>>,
stats: Option<Res<StatsResource>>,
font_res: Option<Res<FontResource>>,
avatar: Option<Res<AvatarResource>>,
screens: Query<Entity, With<ProfileScreen>>,
scrims: Query<(), With<ModalScrim>>,
) {
let button_clicked = requests.read().count() > 0;
let p_pressed = keys.just_pressed(KeyCode::KeyP);
let esc_pressed = keys.just_pressed(KeyCode::Escape);
let already_open = !screens.is_empty();
// P / button toggles open-or-close. Esc only ever closes — when
// Profile is layered over Home (clicking the new Home stats chip
// opens this on top), Esc must dismiss the *topmost* modal.
// Without this branch, Esc fell through to Home's cancel handler
// and closed the wrong modal.
let want_open = !already_open && (p_pressed || button_clicked);
let want_close = already_open && (p_pressed || button_clicked || esc_pressed);
if !want_open && !want_close {
return;
}
if want_open && !scrims.is_empty() {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
spawn_profile_screen(
&mut commands,
settings.as_deref(),
sync_status.as_deref(),
progress.as_deref(),
achievements.as_deref(),
stats.as_deref(),
font_res.as_deref(),
avatar.as_deref(),
);
}
}
#[allow(clippy::too_many_arguments)]
fn spawn_profile_screen(
commands: &mut Commands,
pub(crate) fn spawn_profile_body(
card: &mut ChildSpawnerCommands,
settings: Option<&SettingsResource>,
sync_status: Option<&SyncStatusResource>,
progress: Option<&ProgressResource>,
@@ -208,9 +138,7 @@ fn spawn_profile_screen(
..default()
};
let scrim = spawn_modal(commands, ProfileScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Profile", font_res);
{
// Scrollable body — the Profile panel renders sync info,
// progression (incl. a 14-day calendar), every unlocked
// achievement (up to ~18), and a stats summary, which can
@@ -472,20 +400,7 @@ fn spawn_profile_screen(
));
}
});
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
ProfileCloseButton,
"Done",
Some("P"),
ButtonVariant::Primary,
font_res,
);
});
});
// Profile is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
}
/// Spawn a fixed-height vertical spacer node.
@@ -627,6 +542,7 @@ mod tests {
use crate::settings_plugin::SettingsPlugin;
use crate::stats_plugin::StatsPlugin;
use crate::table_plugin::TablePlugin;
use crate::ui_modal::ModalScrim;
fn headless_app() -> App {
let mut app = App::new();
@@ -637,7 +553,8 @@ mod tests {
.add_plugins(ProgressPlugin::headless())
.add_plugins(AchievementPlugin::headless())
.add_plugins(SettingsPlugin::headless())
.add_plugins(ProfilePlugin);
.add_plugins(ProfilePlugin)
.add_plugins(crate::you_hub_plugin::YouHubPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
@@ -1,5 +1,3 @@
#![allow(dead_code)]
use bevy::prelude::*;
use super::*;
@@ -27,960 +25,6 @@ pub(crate) struct ReplayScrubKeyHold {
pub(crate) right_held_secs: f32,
}
/// Marker on the keybind-hint footer row at the bottom edge of the
/// banner. Carries two `Text` children: a vim-style mode indicator
/// (`▌ NORMAL │ replay`) on the left and the keybind hint
/// (`[SPACE] pause/resume`) on the right. 1 px top border in
/// [`BORDER_SUBTLE`] separates it from the notch-label row above.
///
/// Surfaces the existing Space-key accelerator visually so the
/// UI-first contract from CLAUDE.md §3.3 (every player action has
/// a visible UI control) holds for keyboard accelerators too.
/// Future commits that wire ESC for stop or ← / → for scrub will
/// extend the right-hand text in lockstep — the footer always
/// reflects what's actually wired, never aspirational.
#[derive(Component, Debug)]
pub struct ReplayOverlayKeybindFooter;
/// Marker on the bottom-edge **Move Log** panel — a separate root
/// UI entity (not a child of the banner) that sits anchored to the
/// viewport's bottom edge. Carries a header (`▌ MOVE LOG · N/M`)
/// plus a row showing the most-recently-applied move.
///
/// Spawned by `spawn_overlay` alongside the banner and the
/// floating progress chip; despawned by `react_to_state_change`
/// on the same `Playing → Inactive` transition. Same lifecycle
/// pattern as `ReplayFloatingProgressChip` — a sibling root, not
/// a banner child, because it lives at a different screen anchor.
///
/// First slice of the move-log mockup at
/// `docs/ui-mockups/replay-overlay-mobile.html` § "Move Log Card".
/// Subsequent commits add prev/next rows and scrolling.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogPanel;
/// Marker on the move-log panel's header `Text`. Carries
/// `▌ MOVE LOG · N/M` while a replay is playing; the
/// `update_move_log_header` system repaints it as the cursor
/// advances.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogHeader;
/// Marker on the move-log panel's active-row `Text`. Carries the
/// most-recently-applied move's text (`47 │ waste → tableau 5`)
/// when `cursor > 0`; empty when no moves have been applied yet
/// (initial spawn) or in `Completed`/`Inactive` states. The
/// `update_move_log_active_row` system repaints it as the cursor
/// advances.
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogActiveRow;
/// Marker on a "previous move" row above the active row.
/// `offset` is the 1-based distance backwards from the active
/// row: `offset = 1` is the move applied just before the active
/// one (e.g. cursor=47 → row reads "46 │ ..."), `offset = 2` is
/// the one before that, and so on. Up to [`MOVE_LOG_PREV_ROWS`]
/// rows render above the active row.
///
/// Empty text when there isn't enough history (`offset >= cursor`,
/// e.g. cursor=1 has no prev rows; cursor=2 has only the
/// `offset = 1` row populated).
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogPrevRow {
/// Distance backwards from the active row (1-based).
pub offset: u8,
}
/// Marker on a "next move" row below the active row. `offset`
/// is the 1-based distance forward from the active row:
/// `offset = 1` is the move that will apply next
/// (`replay.moves[cursor]`, displayed as `cursor + 1`),
/// `offset = 2` is the one after that, and so on. Up to
/// [`MOVE_LOG_NEXT_ROWS`] rows render below the active row.
///
/// Empty text when there isn't enough remaining replay
/// (`cursor + offset - 1 >= moves.len()`, e.g. cursor=99 of
/// a 100-move replay shows offset 1 but offset 2 stays empty).
#[derive(Component, Debug)]
pub struct ReplayOverlayMoveLogNextRow {
/// Distance forward from the active row (1-based).
pub offset: u8,
}
/// Marker added to every top-level entity spawned by [`spawn_overlay`].
/// `react_to_state_change` uses a single `Query<Entity, With<DespawnWithReplay>>`
/// to despawn all of them, rather than keeping a separate query per
/// entity type. Future sibling overlay surfaces just need this marker
/// at spawn time — no changes to the despawn logic required.
#[derive(Component, Debug)]
pub struct DespawnWithReplay;
/// Marker on the mini-tableau preview panel root. A right-edge-anchored
/// panel that shows a compact summary of the live game state during
/// replay: the four foundation tops and the stock / waste heads.
/// Spawned as a sibling root entity (same lifecycle pattern as
/// [`ReplayOverlayMoveLogPanel`]) at `right: 0`, `top: MINI_TABLEAU_TOP_OFFSET`.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauPanel;
/// Marker on the foundations row `Text` inside the mini-tableau panel.
/// Carries `F: A♠ 7♥ 5♦ K♣` (or `--` for empty slots); repainted by
/// `update_mini_tableau` whenever [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauFoundations;
/// Marker on the stock/waste row `Text` inside the mini-tableau panel.
/// Carries `STK:14 WST:7♥`; repainted by `update_mini_tableau` whenever
/// [`GameStateResource`] changes.
#[derive(Component, Debug)]
pub struct ReplayMiniTableauStockWaste;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Bevy plugin that registers every system needed to drive the replay
/// overlay's lifecycle.
///
/// The plugin is independent of [`crate::replay_playback::ReplayPlaybackPlugin`]
/// — it only reads the shared `ReplayPlaybackState` resource. Tests insert
/// the resource manually and exercise the overlay in isolation.
pub struct ReplayOverlayPlugin;
impl Plugin for ReplayOverlayPlugin {
fn build(&self, app: &mut App) {
// The systems are ordered so that, on a single frame:
// 1. The state-watcher spawns or despawns the overlay if the
// `ReplayPlaybackState` resource changed.
// 2. The completion-text update swaps the banner label when the
// state is `Completed`.
// 3. The progress-text update writes the latest "Move N of M".
// 4. The Stop-button click handler reads `Interaction::Pressed`
// and calls `stop_replay_playback` (which mutates the state).
// Putting Stop last means a click in frame N is observed by
// `react_to_state_change` in frame N+1, which then despawns the
// overlay in response — a clean state-driven loop.
// Step-button handler dispatches into the same canonical move
// / draw events that the tick loop fires. Register them
// defensively here so this plugin can run under
// `MinimalPlugins` without the playback plugin attached;
// `add_message` is idempotent so the duplicate registration
// in production (alongside `replay_playback`) is harmless.
app.init_resource::<ReplayScrubKeyHold>()
.add_message::<MoveRequestEvent>()
.add_message::<DrawRequestEvent>()
.add_message::<UndoRequestEvent>()
.add_message::<StateChangedEvent>()
.add_systems(
Update,
(
react_to_state_change,
update_banner_label,
update_progress_text,
update_floating_progress_chip,
update_scrub_fill,
update_move_log_header,
update_move_log_active_row,
update_move_log_prev_rows,
update_move_log_next_rows,
update_mini_tableau_foundations,
update_mini_tableau_stock_waste,
update_pause_button_label,
handle_pause_button,
handle_step_button,
handle_pause_keyboard,
handle_stop_keyboard,
handle_arrow_keyboard,
handle_stop_button,
)
.chain(),
);
}
}
// ---------------------------------------------------------------------------
// Spawning
// ---------------------------------------------------------------------------
/// Reads [`ReplayPlaybackState`] every time the resource changes and either
/// spawns or despawns the overlay accordingly. Treats the resource as the
/// single source of truth — the spawn / despawn decision is derived from
/// `is_playing() || is_completed()` rather than tracking previous-state
/// transitions explicitly, which keeps the system stateless.
pub(crate) fn react_to_state_change(
mut commands: Commands,
state: Res<ReplayPlaybackState>,
roots: Query<Entity, With<ReplayOverlayRoot>>,
despawnable: Query<Entity, With<DespawnWithReplay>>,
font_res: Option<Res<FontResource>>,
) {
if !state.is_changed() {
return;
}
let should_be_visible = state.is_playing() || state.is_completed();
let already_spawned = roots.iter().next().is_some();
if should_be_visible && !already_spawned {
spawn_overlay(&mut commands, font_res.as_deref(), &state);
} else if !should_be_visible && already_spawned {
// Despawn all sibling root entities in one loop — every entity
// spawned by `spawn_overlay` carries `DespawnWithReplay` for
// exactly this purpose.
for entity in &despawnable {
commands.entity(entity).despawn();
}
}
// The `should_be_visible && already_spawned` branch is a no-op here —
// the per-frame text update systems below repaint the banner label
// and progress readout in place without a respawn.
}
/// Spawns the banner — a flex-row Node anchored to the top edge of the
/// window with three children: the "▌ replay" / "▌ replay complete" label,
/// the centred progress text, and the right-aligned Stop button.
pub(crate) fn spawn_overlay(
commands: &mut Commands,
font_res: Option<&FontResource>,
state: &ReplayPlaybackState,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
// Clone for the floating chip spawn that runs *after* the
// banner's `.with_children(|banner| { ... })` closure consumes
// the original `font_handle`. Cheap — Bevy's `Handle<Font>` is
// `Arc`-backed, the clone bumps a refcount.
let font_handle_for_floating = font_handle.clone();
// Second clone for the scrub-bar label row and keybind footer
// inside the outer banner closure. The inner top-row closure
// consumes the original `font_handle` for the progress-chip
// text, so by the time the outer closure reaches the
// label-row / footer spawns the original is gone.
// `font_handle_for_labels` is `.clone()`'d (never moved) inside
// the labels closure, so it's still alive for the footer
// spawn afterwards — single shared clone covers both.
let font_handle_for_labels = font_handle.clone();
// Third clone for the move-log panel — a separate root
// entity spawned after the banner closure closes. Mirrors the
// floating-chip clone reasoning.
let font_handle_for_move_log = font_handle.clone();
// Fourth clone for the mini-tableau preview panel.
let font_handle_for_mini_tableau = font_handle.clone();
let banner_label = if state.is_completed() {
"\u{258C} replay complete" // ▌ — cursor-block prefix; matches the splash boot-screen convention.
} else {
"\u{258C} replay" // ▌
};
let progress_label = format_progress(state);
// Tableau dim layer — full-screen scrim at z = Z_REPLAY_DIM (= 54).
// Spawned first so it sits behind the banner (z=55) and move-log (z=55)
// in the UI stacking context. World-space sprites (cards, badges) are
// always below any UI node, so the dim layer darkens the entire
// gameplay scene without needing to touch card_plugin. No Interaction
// component — purely visual.
commands.spawn((
ReplayTableauDimLayer,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(Color::srgba(0.0, 0.0, 0.0, TABLEAU_DIM_ALPHA)),
ZIndex(Z_REPLAY_DIM),
GlobalZIndex(Z_REPLAY_DIM),
));
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayRoot,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
top: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(BANNER_HEIGHT),
// Column outer so the content row sits above the 1px
// scrub bar at the bottom edge.
flex_direction: FlexDirection::Column,
..default()
},
BackgroundColor(banner_bg),
// Pin the banner to its z layer in both the local and the
// global stacking context — `GlobalZIndex` matters because
// the overlay is a top-level Node (no parent), and Bevy 0.18
// has historically had subtle stacking-context drift here.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
))
.with_children(|banner| {
// Top row: the existing content (label / progress / Stop).
banner
.spawn(Node {
flex_grow: 1.0,
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
justify_content: JustifyContent::SpaceBetween,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
column_gap: VAL_SPACE_4,
..default()
})
.with_children(|row| {
// Left: column with the accent "▌ replay" headline
// above and a small `GAME #YYYY-DDD` caption below.
// The caption mirrors the mockup's right-anchored
// game identifier but stays visually grouped with
// the headline so the two pieces of "this is a
// replay of game X" read as a single unit.
row.spawn(Node {
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: Val::Px(2.0),
..default()
})
.with_children(|left| {
left.spawn((
ReplayOverlayBannerText,
Text::new(banner_label),
TextFont {
font: font_handle.clone(),
font_size: TYPE_HEADLINE,
..default()
},
TextColor(ACCENT_PRIMARY),
));
left.spawn((
ReplayOverlayGameCaption,
Text::new(format_game_caption(state).unwrap_or_default()),
TextFont {
font: font_handle.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
// Centre: progress readout, wrapped in a 1 px
// ACCENT_PRIMARY-bordered chip so it reads as a
// discrete callout rather than free-floating
// text. No fill — the Terminal aesthetic gets
// depth from borders + tonal layering, not
// shadows. The marker stays on the inner Text so
// `update_progress_text` keeps working unchanged.
row.spawn((
Node {
border: UiRect::all(Val::Px(1.0)),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
..default()
},
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|chip| {
chip.spawn((
ReplayOverlayProgressText,
Text::new(progress_label),
TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
));
});
// Right: Stop button. Tertiary variant — the
// action is available but not the loudest element
// in the banner; the "Replay" primary accent owns
// that slot. `spawn_modal_button` gives us hover /
// press paint and focus rings for free via the
// existing `UiModalPlugin` paint system.
row.spawn(Node {
flex_direction: FlexDirection::Row,
align_items: AlignItems::Center,
column_gap: VAL_SPACE_2,
..default()
})
.with_children(|wrap| {
// Pause / Resume label is set from the current
// state so a freshly-spawned overlay (which
// currently always starts unpaused) reads
// "Pause". `update_pause_button_label`
// repaints it whenever the state changes.
spawn_modal_button(
wrap,
ReplayPauseButton,
pause_button_label(state),
None,
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
wrap,
ReplayStepButton,
"Step",
None,
ButtonVariant::Tertiary,
font_res,
);
spawn_modal_button(
wrap,
ReplayStopButton,
"Stop",
None,
ButtonVariant::Tertiary,
font_res,
);
});
});
// Bottom edge: 1px-tall scrub bar. Track in `BORDER_SUBTLE`,
// fill in `ACCENT_PRIMARY`. The fill width is rewritten by
// [`update_scrub_fill`] every tick the cursor advances.
// Initial fill width matches the spawn-time progress so the
// first-frame paint already reflects state instead of
// popping from 0 → cursor on the first tick.
let initial_scrub_pct = scrub_pct(state);
let win_pct = win_move_marker_pct(state);
banner
.spawn((
Node {
width: Val::Percent(100.0),
height: Val::Px(1.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
// HC marker: bumps the 1 px track from #505050
// → #a0a0a0 under high-contrast mode. The track
// paints via BackgroundColor (it's a 1 px Node,
// not a border on a wider container) so the
// BorderColor-targeting HighContrastBorder marker
// doesn't apply — HighContrastBackground is the
// parallel primitive for this case.
HighContrastBackground::with_default(BORDER_SUBTLE),
))
.with_children(|track| {
track.spawn((
ReplayOverlayScrubFill,
Node {
width: Val::Percent(initial_scrub_pct),
height: Val::Percent(100.0),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
));
// WIN MOVE marker — small green tick anchored at
// `win_move_index / total`. Spawned only when the
// active replay carries the field; older replays
// pre-dating `win_move_index` simply don't get a
// marker. Centered vertically on the 1px track via
// a 3px-tall node offset 1px above the track top so
// 1px sits above and 1px below the track line.
if let Some(pct) = win_pct {
track.spawn((
ReplayOverlayWinMoveMarker,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(pct),
top: Val::Px(-1.0),
width: Val::Px(2.0),
height: Val::Px(3.0),
..default()
},
BackgroundColor(STATE_SUCCESS),
// HC bump: lime → brighter lime so the win
// marker reads clearly above the bumped
// notch ticks (BORDER_SUBTLE_HC gray) under
// high-contrast mode.
HighContrastBackground::with_hc(STATE_SUCCESS, STATE_SUCCESS_HC),
));
}
// Fixed quarter-mark notches: five 1px vertical
// ticks at 0 / 25 / 50 / 75 / 100 % that give the
// player visual anchor points without needing to
// mentally bisect the bar. Painted in
// BORDER_SUBTLE — same colour as the unfilled
// track — so visibility comes from extending past
// the 1px track height (5px tall, anchored 2px
// above the track top) rather than colour
// contrast. Spawned *after* the WIN MOVE marker
// so a notch and the marker landing on the same
// percentage paint the marker on top.
for pct in scrub_notch_positions() {
track.spawn((
ReplayOverlayScrubNotch,
Node {
position_type: PositionType::Absolute,
left: Val::Percent(pct),
top: Val::Px(-2.0),
width: Val::Px(1.0),
height: Val::Px(5.0),
..default()
},
BackgroundColor(BORDER_SUBTLE),
// Same HC-paint reasoning as the track
// above: 5 px tall × 1 px wide tick mark
// paints via BackgroundColor, so
// HighContrastBackground (not -Border) is
// the right marker.
HighContrastBackground::with_default(BORDER_SUBTLE),
));
}
});
// Third banner row: percentage labels (`0%` / `25%` /
// `50%` / `75%` / `100%`) under each scrub-bar notch.
// Sibling of (not child of) the 1px track because labels
// need their own vertical real estate (TYPE_CAPTION text
// doesn't fit inside a 1px container). Position math:
// track Node has `Val::Percent(p)` referencing the
// banner's full width; this label row also has the
// banner's full width, so labels at the same
// percentages line up vertically with their notches.
let labels = scrub_notch_labels();
let positions = scrub_notch_positions();
banner
.spawn(Node {
width: Val::Percent(100.0),
height: Val::Px(SCRUB_LABEL_ROW_HEIGHT),
position_type: PositionType::Relative,
..default()
})
.with_children(|row| {
for (i, (label, pct)) in labels.iter().zip(positions.iter()).enumerate() {
// Endpoints flush to the row's edges; middle
// three labels use the `translateX(-50%)`
// pattern for Bevy 0.18 UI: a fixed-width
// container is placed at `left: Percent(pct)`
// then shifted left by half its own width via
// `margin.left: Px(-SCRUB_LABEL_CENTER_WIDTH/2)`.
// `Justify::Center` renders the text centred
// within the container so the text's visual
// centre coincides with the notch line.
let (node, justify) = if i == 0 {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Px(0.0),
..default()
},
Justify::Left,
)
} else if i == labels.len() - 1 {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
right: Val::Px(0.0),
..default()
},
Justify::Right,
)
} else {
(
Node {
position_type: PositionType::Absolute,
top: Val::Px(2.0),
left: Val::Percent(*pct),
width: Val::Px(SCRUB_LABEL_CENTER_WIDTH),
margin: UiRect {
left: Val::Px(-SCRUB_LABEL_CENTER_WIDTH / 2.0),
..default()
},
..default()
},
Justify::Center,
)
};
row.spawn((
ReplayOverlayScrubNotchLabel,
node,
Text::new(*label),
TextLayout::new_with_justify(justify),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
// TEXT_SECONDARY keeps the subdued visual
// hierarchy (caption, not headline) while
// staying readable against BG_ELEVATED_HI.
TextColor(TEXT_SECONDARY),
));
}
});
// Fourth banner row: keybind-hint footer. Vim-style
// mode line on the left (`▌ NORMAL │ replay`), keybind
// hint on the right (`[SPACE] pause/resume`), 1px top
// border in BORDER_SUBTLE separating it from the
// labels row above. Surfaces the existing Space
// accelerator visually so CLAUDE.md §3.3's UI-first
// contract holds for keyboard accelerators too.
banner
.spawn((
ReplayOverlayKeybindFooter,
Node {
width: Val::Percent(100.0),
height: Val::Px(KEYBIND_FOOTER_HEIGHT),
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::SpaceBetween,
align_items: AlignItems::Center,
padding: UiRect::horizontal(VAL_SPACE_4),
border: UiRect::top(Val::Px(1.0)),
..default()
},
BorderColor::all(BORDER_SUBTLE),
// Marker for `apply_high_contrast_borders`: bumps
// the 1 px top border from BORDER_SUBTLE (#505050)
// to BORDER_SUBTLE_HC (#a0a0a0) when
// `Settings::high_contrast_mode` is on. Without
// this the footer reads as floating loose under
// HC because the border that visually anchors it
// to the labels row above is near-invisible.
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|footer| {
footer.spawn((
Text::new(keybind_footer_mode_text()),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
if SHOW_KEYBOARD_ACCELERATORS {
footer.spawn((
Text::new(keybind_footer_hint_text()),
TextFont {
font: font_handle_for_labels.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
});
});
// Floating progress chip — a 2D world-space `Text2d` rendered
// above the destination pile of the most-recently-applied move.
// Sibling of (not child of) the banner overlay because it lives
// in world-space coordinates, not the UI tree. Spawned hidden;
// `update_floating_progress_chip` shows + positions it on the
// first frame the cursor advances past 0. Lifecycle matches
// the banner overlay — `react_to_state_change` despawns both
// when the replay state transitions back to `Inactive`.
commands.spawn((
ReplayFloatingProgressChip,
DespawnWithReplay,
Text2d::new(format_progress(state)),
TextFont {
font: font_handle_for_floating,
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY),
// High Z keeps the chip above every card stack
// (Z_DROP_OVERLAY = 50, Z_STOCK_BADGE = 30, regular cards
// stack to the low double digits at most).
Transform::from_xyz(0.0, 0.0, 100.0),
Visibility::Hidden,
));
// Move-log panel — a separate root UI entity anchored to the
// viewport's bottom edge. Carries a `▌ MOVE LOG · N/M` header
// plus a row showing the most-recently-applied move.
// Sibling-of-banner pattern (not a banner child) because the
// panel lives at a different screen anchor and has its own
// spawn/despawn lifecycle synced via `react_to_state_change`.
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayOverlayMoveLogPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
left: Val::Px(0.0),
bottom: Val::Px(0.0),
width: Val::Percent(100.0),
height: Val::Px(MOVE_LOG_PANEL_HEIGHT),
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
justify_content: JustifyContent::Center,
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
row_gap: VAL_SPACE_1,
border: UiRect::top(Val::Px(1.0)),
..default()
},
BackgroundColor(banner_bg),
BorderColor::all(BORDER_SUBTLE),
// Same z-stack rationale as the banner — above gameplay,
// below modals.
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
// HC marker so the top border bumps under HC mode.
// Without it the panel reads as floating loose because
// the border that anchors it to the gameplay area above
// is near-invisible at #505050.
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|panel| {
// Header row: `▌ MOVE LOG · N/M` in ACCENT_PRIMARY for
// the cursor-block prefix consistency with the banner
// headline.
panel.spawn((
ReplayOverlayMoveLogHeader,
Text::new(format_move_log_header(state)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
));
// Prev rows — render above the active row in display
// order (oldest first), so the active row sits at the
// bottom of the visible window. Spawn from
// MOVE_LOG_PREV_ROWS down to 1 (offset 2, then 1) so
// the highest-offset (oldest) row is topmost in the
// panel's flex column. Each carries
// ReplayOverlayMoveLogPrevRow { offset } — the
// per-frame system reads `offset` and recomputes the
// text on cursor advance. Painted in TEXT_SECONDARY
// so the active row stands out from context rows.
for offset in (1..=MOVE_LOG_PREV_ROWS as u8).rev() {
panel.spawn((
ReplayOverlayMoveLogPrevRow { offset },
Text::new(format_kth_recent_row(state, offset as usize + 1)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
// Active move row. Wrapped in a Node with an
// ACCENT_PRIMARY background so the row reads as
// "current focus" — the player can scan vertically
// and the highlighted row is the move that just
// applied. Empty text at spawn time when cursor=0;
// the per-frame update system populates it as the
// cursor advances. Text colour is TEXT_PRIMARY_HC
// (near-white) for contrast against the brick-red
// background — same trick as the modal-button
// primary-variant paint.
panel
.spawn((
Node {
width: Val::Percent(100.0),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_1),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|active| {
active.spawn((
ReplayOverlayMoveLogActiveRow,
Text::new(format_active_move_row(state)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_PRIMARY_HC),
));
});
// Next rows — render below the active row in display
// order (offset 1 directly below active, then offset
// 2). Same TEXT_SECONDARY de-emphasis as prev rows so
// the active row stays the focal point. Empty text
// late in the replay (when cursor + offset exceeds
// moves.len()) — the panel under-fills gracefully.
for offset in 1..=MOVE_LOG_NEXT_ROWS as u8 {
panel.spawn((
ReplayOverlayMoveLogNextRow { offset },
Text::new(format_kth_next_row(state, offset as usize)),
TextFont {
font: font_handle_for_move_log.clone(),
font_size: TYPE_BODY,
..default()
},
TextColor(TEXT_SECONDARY),
));
}
});
// Mini-tableau preview panel — right-edge anchor, just below the banner.
// Compact two-row readout: foundation tops then stock/waste head.
// Sibling-of-banner pattern (separate root entity, own spawn/despawn).
let banner_bg = Color::srgba(
BG_ELEVATED_HI.to_srgba().red,
BG_ELEVATED_HI.to_srgba().green,
BG_ELEVATED_HI.to_srgba().blue,
BANNER_ALPHA,
);
commands
.spawn((
ReplayMiniTableauPanel,
DespawnWithReplay,
Node {
position_type: PositionType::Absolute,
right: Val::Px(0.0),
top: Val::Px(MINI_TABLEAU_TOP_OFFSET),
padding: UiRect::axes(VAL_SPACE_2, VAL_SPACE_2),
flex_direction: FlexDirection::Column,
align_items: AlignItems::FlexStart,
row_gap: VAL_SPACE_1,
border: UiRect::left(Val::Px(1.0)),
..default()
},
BackgroundColor(banner_bg),
BorderColor::all(BORDER_SUBTLE),
ZIndex(Z_REPLAY_OVERLAY),
GlobalZIndex(Z_REPLAY_OVERLAY),
HighContrastBorder::with_default(BORDER_SUBTLE),
))
.with_children(|panel| {
panel.spawn((
Text::new("\u{258C} BOARD"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(ACCENT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauFoundations,
Text::new("F: -- -- -- --"),
TextFont {
font: font_handle_for_mini_tableau.clone(),
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_PRIMARY),
));
panel.spawn((
ReplayMiniTableauStockWaste,
Text::new("STK:-- WST:--"),
TextFont {
font: font_handle_for_mini_tableau,
font_size: TYPE_CAPTION,
..default()
},
TextColor(TEXT_SECONDARY),
));
});
}
/// Pure helper — returns the scrub-fill width as a percentage of the
/// track for the given playback state. `Completed` reads as 100 %;
/// `Inactive` and `Playing` with no progress read as 0 %.
pub(crate) fn scrub_pct(state: &ReplayPlaybackState) -> f32 {
if state.is_completed() {
return 100.0;
}
match state.progress() {
Some((_, 0)) | None => 0.0,
Some((cursor, total)) => {
let frac = (cursor as f32 / total as f32).clamp(0.0, 1.0);
frac * 100.0
}
}
}
/// Pure helper — returns the fixed scrub-bar notch positions as
/// percentages along the track. Five evenly-spaced notches at the
/// quarter-marks: `[0, 25, 50, 75, 100]`. Function (rather than
/// const) so the unit-test surface is obvious and a future
/// regression — e.g. someone simplifying to three notches — fails
/// at the helper test rather than at visual review.
pub(crate) fn scrub_notch_positions() -> [f32; 5] {
[0.0, 25.0, 50.0, 75.0, 100.0]
}
/// Pure helper — returns the percentage-label text for each notch,
/// in left-to-right order. Paired with [`scrub_notch_positions`] so
/// `labels[i]` belongs at `positions[i]`. Lifted to a function for
/// the same reason as the positions helper: a clean unit-test
/// surface that fails at a regression (e.g. someone simplifying
/// `100%` → `MAX`) rather than at visual review.
pub(crate) fn scrub_notch_labels() -> [&'static str; 5] {
["0%", "25%", "50%", "75%", "100%"]
}
/// Pure helper — returns the vim-style mode indicator text shown on
/// the left side of the keybind-hint footer row. `▌ NORMAL │ replay`
/// matches the `▌replay.tsx` motif from the splash boot-screen and
/// the screen-takeover mockup. The cursor block (`▌`) matches the
/// banner-label prefix; "NORMAL" is the vim mode (mockup parity);
/// "replay" identifies the surface.
pub(crate) fn keybind_footer_mode_text() -> &'static str {
"\u{258C} NORMAL \u{2502} replay" // ▌ NORMAL │ replay
}
/// Pure helper — returns the keybind-hint text shown on the right
/// side of the keybind-hint footer row. Lists only the keys that
/// are *actually wired* today: the Space accelerator for
/// pause/resume, the ESC accelerator for stop, and the ← / →
/// accelerators for paused single-move stepping. The footer never
/// lists unimplemented keybinds (would lie to users).
pub(crate) fn keybind_footer_hint_text() -> &'static str {
if SHOW_KEYBOARD_ACCELERATORS {
"[SPACE] pause/resume \u{00B7} [ESC] stop \u{00B7} [\u{2190}\u{2192}] step" // · separator
} else {
""
}
}
/// Pure helper — returns the WIN MOVE marker's left-edge position as
/// a percentage of the scrub track, or `None` when no marker should
/// be drawn.
///
/// `None` is returned in any of these cases:
/// - The state isn't `Playing` (no replay attached).
/// - The replay's `win_move_index` is `None` (older replay loaded
/// from disk pre-dating the field).
/// - The replay's move list is empty (shouldn't happen for real wins,
/// but guards the divide-by-zero).
///
/// The percentage clamps to `[0, 100]` so a malformed
/// `win_move_index >= total` (defensive — shouldn't happen) doesn't
/// position the marker outside the track.
pub(crate) fn win_move_marker_pct(state: &ReplayPlaybackState) -> Option<f32> {
let ReplayPlaybackState::Playing { replay, .. } = state else {
return None;
};
let idx = replay.win_move_index?;
let total = replay.moves.len();
if total == 0 {
return None;
}
let frac = (idx as f32 / total as f32).clamp(0.0, 1.0);
Some(frac * 100.0)
}
// ---------------------------------------------------------------------------
// Playback-control button handlers
// ---------------------------------------------------------------------------
@@ -50,15 +50,21 @@ pub(super) fn handle_volume_keys(
}
/// Opens or closes the Settings panel — `O` keyboard accelerator or
/// `ToggleSettingsRequestEvent` from the HUD Menu popover.
/// `ToggleSettingsRequestEvent` from the HUD Menu popover. Esc closes
/// too (Phase C dismissal audit), but only when Settings is the
/// topmost modal — with sync-setup or the theme store stacked on top,
/// the stacked dialog owns Esc.
pub(super) fn toggle_settings_screen(
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleSettingsRequestEvent>,
mut screen: ResMut<SettingsScreen>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<SettingsPanel>)>,
) {
let button_clicked = requests.read().count() > 0;
if keys.just_pressed(KeyCode::KeyO) || button_clicked {
screen.0 = !screen.0;
} else if keys.just_pressed(KeyCode::Escape) && screen.0 && other_modal_scrims.is_empty() {
screen.0 = false;
}
}
@@ -140,6 +140,31 @@ fn pressing_o_toggles_settings_screen_flag() {
);
}
/// Esc closes the Settings panel like O / Done do (Phase C dismissal
/// audit). Esc while the panel is closed must NOT open it.
#[test]
fn escape_closes_settings_screen_flag() {
let mut app = headless_app();
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc on a closed panel stays closed"
);
press(&mut app, KeyCode::KeyO);
app.update();
assert!(app.world().resource::<SettingsScreen>().0, "O opens");
press(&mut app, KeyCode::Escape);
app.update();
assert!(
!app.world().resource::<SettingsScreen>().0,
"Esc closes settings"
);
}
// cycle_unlocked pure-function tests
#[test]
fn cycle_unlocked_wraps_at_end() {
+11 -29
View File
@@ -18,6 +18,7 @@ use crate::theme::{ImportError, import_theme, refresh_registry};
use crate::ui_focus::FocusRow;
use crate::ui_modal::{
ButtonVariant, spawn_modal, spawn_modal_actions, spawn_modal_button, spawn_modal_header,
spawn_tab_chip,
};
use crate::ui_theme::{
BG_BASE, BG_ELEVATED, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM,
@@ -117,41 +118,22 @@ pub(super) fn spawn_settings_panel(
});
}
/// One Settings tab chip. The active chip is filled + bright; inactive
/// chips are quiet outlines.
/// One Settings tab chip — thin wrapper over the shared
/// [`spawn_tab_chip`] widget so Settings and the You hub stay visually
/// identical.
fn tab_chip(
parent: &mut ChildSpawnerCommands,
tab: SettingsTab,
active: bool,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
SettingsTabButton(tab),
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(tab.label()),
font,
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
));
});
spawn_tab_chip(
parent,
SettingsTabButton(tab),
tab.label(),
active,
font_res,
);
}
/// Audio tab: the two volume rows.
+90 -106
View File
@@ -8,7 +8,6 @@
use std::path::PathBuf;
use bevy::input::ButtonInput;
use bevy::input::mouse::{MouseScrollUnit, MouseWheel};
use bevy::prelude::*;
use solitaire_data::{
@@ -25,17 +24,13 @@ use crate::events::{
use crate::font_plugin::FontResource;
use crate::game_plugin::GameMutation;
use crate::platform::ClipboardBackendResource;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{
ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header,
};
use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
use crate::ui_theme::{
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,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
};
/// Bevy resource wrapping the current stats.
@@ -225,8 +220,9 @@ impl Plugin for StatsPlugin {
.before(GameMutation)
.before(update_stats_on_new_game),
)
.add_systems(Update, toggle_stats_screen.after(GameMutation))
.add_systems(Update, handle_stats_close_button)
// Open/close/tab handling moved to `you_hub_plugin`
// (Phase E) — this plugin now owns body content, replay
// selector behavior, and scroll.
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
.add_systems(Update, handle_watch_replay_button)
.add_systems(Update, handle_copy_share_link_button)
@@ -632,70 +628,17 @@ fn handle_forfeit(
}
}
/// Marker on the "Done" button inside the Stats modal. Click despawns
/// the overlay; `S` keyboard shortcut toggles it the same way.
#[derive(Component, Debug)]
pub struct StatsCloseButton;
#[allow(clippy::too_many_arguments)]
fn toggle_stats_screen(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleStatsRequestEvent>,
stats: Res<StatsResource>,
progress: Option<Res<ProgressResource>>,
time_attack: Option<Res<TimeAttackResource>>,
font_res: Option<Res<FontResource>>,
latest_replay: Res<ReplayHistoryResource>,
selected_index: Res<SelectedReplayIndex>,
screens: Query<Entity, With<StatsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
) {
let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
return;
}
if let Ok(entity) = screens.single() {
commands.entity(entity).despawn();
} else {
if !other_modal_scrims.is_empty() {
return;
}
spawn_stats_screen(
&mut commands,
&stats.0,
progress.as_deref().map(|p| &p.0),
time_attack.as_deref(),
font_res.as_deref(),
&latest_replay.0.replays,
selected_index.0,
);
}
}
/// Click handler for the modal's "Done" button — despawns the overlay
/// the same way the `S` accelerator does.
fn handle_stats_close_button(
mut commands: Commands,
close_buttons: Query<&Interaction, (With<StatsCloseButton>, Changed<Interaction>)>,
screens: Query<Entity, With<StatsScreen>>,
) {
if !close_buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
fn spawn_stats_screen(
commands: &mut Commands,
/// Builds the Stats tab body inside the You hub's card: the 8-cell
/// grid plus per-mode bests, progression, weekly goals, unlocks, and
/// the optional Time Attack line. The replay selector lives on its own
/// hub tab now — see [`spawn_replays_body`]. All markers
/// (`StatsCell`, `PerModeBestsRow`, `StatsScrollable`) are unchanged.
pub(crate) fn spawn_stats_body(
card: &mut ChildSpawnerCommands,
stats: &StatsSnapshot,
progress: Option<&PlayerProgress>,
time_attack: Option<&TimeAttackResource>,
font_res: Option<&FontResource>,
replays: &[Replay],
selected_index: usize,
) {
// --- primary stat cells ---
// First-launch zero-state: when no games have been played yet, render
@@ -756,9 +699,7 @@ fn spawn_stats_screen(
..default()
};
let scrim = spawn_modal(commands, StatsScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "Statistics", font_res);
{
// Scrollable body — the Stats panel renders an 8-cell grid plus
// multiple sections (per-mode bests, progression, weekly goals,
// unlocks, optional Time Attack, latest replay caption) and
@@ -935,7 +876,45 @@ fn spawn_stats_screen(
TextColor(STATE_WARNING),
));
}
});
}
}
/// Builds the Replays tab body inside the You hub's card: the
/// Prev/Next selector, detail line, and the Watch / Copy-share-link
/// actions (buttons live in the body now — the hub owns the single
/// Done in the action row). All markers (`ReplayPrevButton`,
/// `ReplayNextButton`, `ReplaySelectorCaption`, `ReplaySelectorDetail`,
/// `WatchReplayButton`, `CopyShareLinkButton`) are unchanged, so the
/// selector/watch/copy systems keep working.
pub(crate) fn spawn_replays_body(
card: &mut ChildSpawnerCommands,
replays: &[Replay],
selected_index: usize,
font_res: Option<&FontResource>,
) {
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
let font_row = TextFont {
font: font_handle,
font_size: TYPE_BODY,
..default()
};
{
// Scrollable body — mirrors the other hub tabs so short
// windows can still reach every control.
card.spawn((
StatsScrollable,
ScrollPosition::default(),
Node {
flex_direction: FlexDirection::Column,
row_gap: VAL_SPACE_3,
max_height: Val::Vh(70.0),
overflow: Overflow::scroll_y(),
..default()
},
))
.with_children(|body| {
// --- Replay selector ---
// Prev / Next chips step through the full replay history;
// `repaint_replay_selector_caption` and
@@ -1019,12 +998,21 @@ fn spawn_stats_screen(
));
});
spawn_modal_actions(card, |actions| {
// The Watch Replay button is always rendered so the
// affordance is discoverable from a fresh install. When no
// replay exists, the click handler surfaces a clear
// "No replay recorded yet" toast rather than silently
// doing nothing.
// Tab-local actions. The Watch Replay button is always
// rendered so the affordance is discoverable from a fresh
// install — with no replay, its click handler surfaces a
// "No replay recorded yet" toast rather than silently doing
// nothing. Same policy for Copy share link (toast explains
// when no shareable upload exists).
card.spawn(Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|actions| {
spawn_modal_button(
actions,
WatchReplayButton,
@@ -1033,11 +1021,6 @@ fn spawn_stats_screen(
ButtonVariant::Secondary,
font_res,
);
// Copy share link only renders when a sharable URL is in
// hand. The button is intentionally absent (rather than
// disabled) when no upload has happened yet — keeps the
// action bar free of dead controls in the local-only and
// first-launch cases.
spawn_modal_button(
actions,
CopyShareLinkButton,
@@ -1046,18 +1029,8 @@ fn spawn_stats_screen(
ButtonVariant::Secondary,
font_res,
);
spawn_modal_button(
actions,
StatsCloseButton,
"Done",
Some("S"),
ButtonVariant::Primary,
font_res,
);
});
});
// Stats is read-only — opt into click-outside-to-dismiss.
commands.entity(scrim).insert(ScrimDismissible);
}
}
/// Spawn one row of the "Per-mode bests" section: the mode label on the
@@ -1290,6 +1263,7 @@ mod tests {
// ProgressResource is an optional dependency for the stats screen;
// include it so toggle tests exercise the progression panel.
app.add_plugins(crate::progress_plugin::ProgressPlugin::headless());
app.add_plugins(crate::you_hub_plugin::YouHubPlugin);
app.update();
app
}
@@ -1832,6 +1806,25 @@ mod tests {
// Prev/Next replay selector spawn-site tests
// -----------------------------------------------------------------------
/// Opens the You hub via `S`, then switches to the Replays tab
/// (where the selector lives since Phase E) and settles the rebuild.
fn open_replays_tab(app: &mut App) {
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
// MinimalPlugins never clears input, so the sticky `just_pressed`
// would keep re-requesting the Stats tab every frame.
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.clear();
app.world_mut()
.resource_mut::<crate::you_hub_plugin::ActiveYouTab>()
.0 = crate::you_hub_plugin::YouTab::Replays;
app.update();
app.update();
}
#[test]
fn selector_row_spawns_when_stats_screen_opens() {
let mut app = headless_app();
@@ -1840,10 +1833,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(90, None));
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
open_replays_tab(&mut app);
let prev = app
.world_mut()
@@ -1878,10 +1868,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(120, None));
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
open_replays_tab(&mut app);
let mut q = app
.world_mut()
@@ -1901,10 +1888,7 @@ mod tests {
let mut hist = app.world_mut().resource_mut::<ReplayHistoryResource>();
hist.0.replays.push(make_test_replay(65, None)); // 65s → "1:05"
}
app.world_mut()
.resource_mut::<ButtonInput<KeyCode>>()
.press(KeyCode::KeyS);
app.update();
open_replays_tab(&mut app);
let mut q = app
.world_mut()
+15 -8
View File
@@ -118,6 +118,9 @@ impl Plugin for ThemeStorePlugin {
.init_resource::<CatalogTask>()
.init_resource::<InstallTask>()
.init_resource::<StoreBaseUrl>()
// Esc-close reads keyboard input; register defensively so
// the plugin works under MinimalPlugins in tests.
.init_resource::<ButtonInput<KeyCode>>()
.add_message::<ThemeStoreOpenRequestEvent>()
.add_message::<InfoToastEvent>()
.add_message::<WarningToastEvent>()
@@ -344,19 +347,23 @@ fn poll_install_task(
);
}
/// Despawns the store modal when Close is pressed.
/// Despawns the store modal when Close is pressed or on Esc (Phase C
/// dismissal audit). The store only ever stacks over Settings and
/// nothing stacks over the store, so it owns Esc whenever it is open
/// (Settings' own Esc handler is gated on being topmost).
fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<ThemeStoreCloseButton>)>,
keys: Res<ButtonInput<KeyCode>>,
screens: Query<Entity, With<ThemeStoreScreen>>,
mut commands: Commands,
) {
for interaction in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
for entity in &screens {
commands.entity(entity).despawn();
}
let clicked = interactions.iter().any(|i| *i == Interaction::Pressed);
let esc = keys.just_pressed(KeyCode::Escape) && !screens.is_empty();
if !clicked && !esc {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
+43 -3
View File
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{
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,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, SCRIM, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY_LG,
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5,
scaled_duration,
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
};
// ---------------------------------------------------------------------------
@@ -402,6 +402,46 @@ pub fn spawn_modal_button<M: Component>(
});
}
/// One tab chip for a tabbed modal (Settings, the You hub). The active
/// chip is filled + bright with a success-green border; inactive chips
/// are quiet outlines. `marker` is the plugin's click-target component
/// carrying which tab the chip selects.
pub fn spawn_tab_chip<M: Component>(
parent: &mut ChildSpawnerCommands,
marker: M,
label: &str,
active: bool,
font_res: Option<&FontResource>,
) {
let font = TextFont {
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
font_size: TYPE_CAPTION,
..default()
};
parent
.spawn((
marker,
Button,
Node {
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_2),
justify_content: JustifyContent::Center,
border: UiRect::all(Val::Px(1.0)),
border_radius: BorderRadius::all(Val::Px(RADIUS_SM)),
..default()
},
BackgroundColor(if active { BG_ELEVATED_HI } else { BG_BASE }),
BorderColor::all(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
HighContrastBorder::with_default(if active { STATE_SUCCESS } else { BORDER_SUBTLE }),
))
.with_children(|b| {
b.spawn((
Text::new(label),
font,
TextColor(if active { TEXT_PRIMARY } else { TEXT_SECONDARY }),
));
});
}
// ---------------------------------------------------------------------------
// Generic touch-scroll helper
// ---------------------------------------------------------------------------
+268 -107
View File
@@ -5,6 +5,13 @@
//! started), a full-screen modal is spawned showing score, time, XP, and a
//! "Play Again" button that fires `NewGameRequestEvent` and closes the modal.
//!
//! # Phase G (docs/ui-redesign-2026-07.md) — action hierarchy
//! The modal leads with actions: **Play Again** (primary, Enter
//! accelerator), then **Watch Replay** and **Share Replay** (shared
//! `stats_plugin` markers, so the global handlers there act on the
//! just-won replay), with the score/time/XP recap reading quietly
//! below.
//!
//! # Task #47 — Win fanfare screen-shake
//! When `GameWonEvent` fires, `ScreenShakeResource` is set. A system offsets
//! the `Camera2d` `Transform` each frame with a decaying oscillation until the
@@ -23,7 +30,7 @@ use crate::game_plugin::GameMutation;
use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource;
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{StatsResource, StatsUpdate};
use crate::stats_plugin::{CopyShareLinkButton, StatsResource, StatsUpdate, WatchReplayButton};
use crate::ui_modal::ModalScrim;
use crate::ui_theme::{
ACCENT_PRIMARY, BG_BASE, BG_ELEVATED, MOTION_SCORE_BREAKDOWN_FADE_SECS,
@@ -163,12 +170,15 @@ pub struct SessionAchievements {
#[derive(Component, Debug)]
pub struct WinSummaryOverlay;
/// Marker on the "Play Again" / "Watch Replay" buttons inside the win-summary modal.
/// Marker on the "Play Again" button inside the win-summary modal.
///
/// Watch Replay and Share Replay carry the shared
/// [`WatchReplayButton`] / [`CopyShareLinkButton`] markers from
/// `stats_plugin`, so the global handlers there drive them (both act
/// on [`crate::stats_plugin::SelectedReplayIndex`], which snaps to the
/// just-won replay on every `GameWonEvent`).
#[derive(Component, Debug)]
enum WinSummaryButton {
PlayAgain,
WatchReplay,
}
struct WinSummaryPlayAgainButton;
/// Marker for one row of the win-modal score-breakdown reveal.
///
@@ -230,6 +240,7 @@ impl Plugin for WinSummaryPlugin {
collect_session_achievements,
spawn_win_summary_after_delay,
handle_win_summary_buttons,
close_overlay_on_watch_replay,
handle_win_summary_keyboard,
apply_screen_shake,
reveal_score_breakdown,
@@ -604,50 +615,48 @@ fn spawn_win_summary_after_delay(
}
}
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
/// Handles "Play Again" and "Watch Replay" in the win-summary modal.
/// Handles "Play Again" in the win-summary modal: collapses the
/// overlay and requests a fresh deal. `NewGameRequestEvent::default()`
/// reuses the current game's `GameMode`, and `handle_new_game` reads
/// the deal options (draw mode, difficulty) from `Settings` — so the
/// rematch is "same mode + same options" in one tap.
fn handle_win_summary_buttons(
interaction_query: Query<(&Interaction, &WinSummaryButton), Changed<Interaction>>,
interaction_query: Query<&Interaction, (Changed<Interaction>, With<WinSummaryPlayAgainButton>)>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
mut new_game: MessageWriter<NewGameRequestEvent>,
mut toast: MessageWriter<InfoToastEvent>,
history: Option<Res<crate::stats_plugin::ReplayHistoryResource>>,
mut playback: Option<ResMut<crate::replay_playback::ReplayPlaybackState>>,
) {
// Collect all pressed buttons first to avoid moving `playback` inside the loop.
let pressed: Vec<&WinSummaryButton> = interaction_query
.iter()
.filter(|(i, _)| **i == Interaction::Pressed)
.map(|(_, b)| b)
.collect();
if !interaction_query.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &overlays {
commands.entity(entity).despawn();
}
new_game.write(NewGameRequestEvent::default());
}
for button in pressed {
match button {
WinSummaryButton::PlayAgain => {
for entity in &overlays {
commands.entity(entity).despawn();
}
new_game.write(NewGameRequestEvent::default());
}
WinSummaryButton::WatchReplay => {
let latest = history.as_ref().and_then(|h| h.0.replays.last()).cloned();
match (latest, playback.as_mut()) {
(Some(replay), Some(pb)) => {
for entity in &overlays {
commands.entity(entity).despawn();
}
crate::replay_playback::start_replay_playback(&mut commands, pb, replay);
}
(Some(_), None) => {
toast.write(InfoToastEvent("Replay playback not available".to_string()));
}
(None, _) => {
toast.write(InfoToastEvent("No replay saved yet".to_string()));
}
}
}
}
/// Collapses the win-summary overlay when its "Watch Replay" button is
/// pressed, so playback (started by `stats_plugin`'s global
/// [`WatchReplayButton`] handler reacting to the same press) is not
/// hidden behind the celebration scrim. No-op while the overlay is
/// closed — the Replays-tab copy of the button manages its own modal.
///
/// "Share Replay" ([`CopyShareLinkButton`]) deliberately does NOT
/// close the overlay: the player stays on the celebration while the
/// copy-feedback toast confirms the link.
fn close_overlay_on_watch_replay(
buttons: Query<&Interaction, (Changed<Interaction>, With<WatchReplayButton>)>,
overlays: Query<Entity, With<WinSummaryOverlay>>,
mut commands: Commands,
) {
if overlays.is_empty() {
return;
}
if !buttons.iter().any(|i| *i == Interaction::Pressed) {
return;
}
for entity in &overlays {
commands.entity(entity).despawn();
}
}
@@ -818,18 +827,68 @@ fn spawn_overlay(
));
}
// Score breakdown reveal — replaces the previous single
// "Score:" line with a per-component multi-row layout.
// --- Action hierarchy (Phase G) ---
// Play Again is the hero action; the two replay
// actions sit under it; the stats recap reads quietly
// below all three. Rematch is one tap.
// Play Again (primary, full row)
card.spawn((
WinSummaryPlayAgainButton,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
align_self: AlignSelf::Stretch,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
margin: UiRect::top(VAL_SPACE_2),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(BG_BASE),
));
});
// Watch Replay + Share Replay (secondary, side by side).
// Both reuse the global stats_plugin handlers, which
// target the just-won replay (`SelectedReplayIndex`
// snaps to 0 on every win). Each is always rendered —
// with no replay / no share URL the handler explains
// itself in a toast instead of silently doing nothing.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
column_gap: VAL_SPACE_3,
..default()
})
.with_children(|row| {
spawn_replay_action(row, WatchReplayButton, "Watch Replay");
spawn_replay_action(row, CopyShareLinkButton, "Share Replay");
});
// --- Quiet stats recap, below the actions ---
// Score breakdown reveal — per-component multi-row
// layout with the staggered fade-in.
spawn_score_breakdown(card, &breakdown, anim_speed);
// Time
// Time (demoted to body/secondary — part of the quiet
// recap, not the celebration headline)
card.spawn((
Text::new(format!("Time: {}", format_win_time(pending.time_seconds))),
TextFont {
font_size: TYPE_HEADLINE,
font_size: TYPE_BODY_LG,
..default()
},
TextColor(TEXT_PRIMARY),
TextColor(TEXT_SECONDARY),
));
// XP total
@@ -859,68 +918,40 @@ fn spawn_overlay(
if !session.names.is_empty() {
spawn_achievements_section(card, &session.names);
}
// Button row: Watch Replay + Play Again side by side.
card.spawn(Node {
flex_direction: FlexDirection::Row,
justify_content: JustifyContent::Center,
column_gap: VAL_SPACE_3,
margin: UiRect::top(VAL_SPACE_2),
..default()
})
.with_children(|row| {
// Watch Replay (secondary style)
row.spawn((
WinSummaryButton::WatchReplay,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Watch Replay"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
});
// Play Again (primary style)
row.spawn((
WinSummaryButton::PlayAgain,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
..default()
},
BackgroundColor(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new("Play Again \u{21B5}"),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(BG_BASE),
));
});
});
});
});
}
/// Spawns one secondary (outline-style) replay action button in the
/// win modal's replay row. `marker` is the shared `stats_plugin`
/// click-target component (`WatchReplayButton` / `CopyShareLinkButton`)
/// whose global handler reacts to the press.
fn spawn_replay_action<M: Component>(row: &mut ChildSpawnerCommands, marker: M, label: &str) {
row.spawn((
marker,
Button,
Node {
padding: UiRect::axes(Val::Px(20.0), VAL_SPACE_3),
justify_content: JustifyContent::Center,
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
border: UiRect::all(Val::Px(1.0)),
..default()
},
BackgroundColor(Color::NONE),
BorderColor::all(ACCENT_PRIMARY),
))
.with_children(|b| {
b.spawn((
Text::new(label.to_string()),
TextFont {
font_size: TYPE_BODY_LG,
..default()
},
TextColor(ACCENT_PRIMARY),
));
});
}
/// Maximum number of achievement names shown explicitly in the win modal before
/// the overflow "...and N more" line is shown instead.
const MAX_ACHIEVEMENTS_SHOWN: usize = 3;
@@ -1863,4 +1894,134 @@ mod tests {
assert_eq!(stagger, 0.0);
assert_eq!(fade, 0.0);
}
// -----------------------------------------------------------------------
// Phase G — action hierarchy
// -----------------------------------------------------------------------
/// Like [`make_app`] but with `TimePlugin` disabled and a manual
/// `Time` resource, so tests can step the win-summary delay timer
/// deterministically via `Time::advance_by` (the real clock's
/// microsecond deltas would never reach the 0.5 s threshold).
fn make_app_manual_clock() -> App {
use bevy::time::TimePlugin;
let mut app = App::new();
app.add_plugins(MinimalPlugins.build().disable::<TimePlugin>())
.add_plugins(WinSummaryPlugin)
.insert_resource(StatsResource(StatsSnapshot::default()))
.insert_resource(GameStateResource(GameState::new(
0,
solitaire_core::DrawStockConfig::DrawOne,
)))
.insert_resource(ProgressResource(PlayerProgress::default()));
app.init_resource::<Time>();
app.update();
app
}
/// Drives the real spawn path: fire `GameWonEvent`, then advance
/// `Time` past the 0.5 s celebration delay so
/// `spawn_win_summary_after_delay` spawns the overlay.
fn open_win_overlay(app: &mut App) {
app.world_mut().write_message(GameWonEvent {
score: 1200,
time_seconds: 90,
});
app.update();
{
let mut time = app.world_mut().resource_mut::<Time>();
time.advance_by(std::time::Duration::from_secs_f32(
WIN_SUMMARY_DELAY_SECS + 0.1,
));
}
app.update();
// One more frame so the deferred spawn commands flush.
app.update();
}
fn overlay_count(app: &mut App) -> usize {
app.world_mut()
.query::<&WinSummaryOverlay>()
.iter(app.world())
.count()
}
fn button_entity<M: Component>(app: &mut App) -> Entity {
let entities: Vec<Entity> = app
.world_mut()
.query_filtered::<Entity, With<M>>()
.iter(app.world())
.collect();
assert_eq!(entities.len(), 1, "expected exactly one button");
entities[0]
}
/// The win modal must render all three Phase G actions: the
/// primary Play Again plus the shared Watch/Share replay buttons.
#[test]
fn win_modal_renders_play_again_watch_and_share_actions() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
assert_eq!(overlay_count(&mut app), 1, "overlay must spawn after delay");
button_entity::<WinSummaryPlayAgainButton>(&mut app);
button_entity::<WatchReplayButton>(&mut app);
button_entity::<CopyShareLinkButton>(&mut app);
}
/// Play Again collapses the overlay and requests a fresh deal.
#[test]
fn play_again_press_closes_overlay_and_requests_new_game() {
use bevy::ecs::message::Messages;
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<WinSummaryPlayAgainButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(overlay_count(&mut app), 0, "Play Again must close overlay");
let events = app.world().resource::<Messages<NewGameRequestEvent>>();
assert!(
!events.is_empty(),
"Play Again must write NewGameRequestEvent"
);
}
/// Watch Replay collapses the overlay so playback (driven by the
/// global `stats_plugin` handler on the same press) is visible.
#[test]
fn watch_replay_press_closes_the_overlay() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<WatchReplayButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(
overlay_count(&mut app),
0,
"Watch Replay must close the celebration overlay"
);
}
/// Share Replay leaves the overlay open — the player stays on the
/// celebration while the copy-feedback toast confirms the link.
#[test]
fn share_replay_press_keeps_the_overlay_open() {
let mut app = make_app_manual_clock();
open_win_overlay(&mut app);
let button = button_entity::<CopyShareLinkButton>(&mut app);
app.world_mut()
.entity_mut(button)
.insert(Interaction::Pressed);
app.update();
assert_eq!(
overlay_count(&mut app),
1,
"Share Replay must not close the overlay"
);
}
}
+486
View File
@@ -0,0 +1,486 @@
//! The "You" hub — Profile · Stats · Achievements · Replays folded
//! into one tabbed modal (Phase E of `docs/ui-redesign-2026-07.md`).
//!
//! The four screens were previously standalone modals reached one at a
//! time through the HUD popover. The hub owns the modal shell (header,
//! tab chips via the shared [`spawn_tab_chip`] widget, a single Done
//! button); each tab's content is a body builder that lives in its
//! original plugin (`spawn_profile_body`, `spawn_stats_body`,
//! `spawn_achievements_body`, `spawn_replays_body`) so every marker
//! component and per-row update system keeps working unchanged.
//!
//! Legacy screen markers (`ProfileScreen`, `StatsScreen`,
//! `AchievementsScreen`) are inserted on the hub scrim while their tab
//! is active, so existing queries and tests keep their meaning.
//!
//! Open paths: `ToggleProfileRequestEvent` / `ToggleStatsRequestEvent`
//! / `ToggleAchievementsRequestEvent` (HUD popover) and the P / S / A
//! accelerators — each opens the hub pre-selected to its tab, toggles
//! the hub closed when its tab is already showing, or switches tabs
//! when a different tab is showing. Esc, Done, and scrim-click close.
use bevy::ecs::system::SystemParam;
use bevy::prelude::*;
use solitaire_data::StatsSnapshot;
use crate::achievement_plugin::{AchievementsResource, AchievementsScreen};
use crate::events::{
ToggleAchievementsRequestEvent, ToggleProfileRequestEvent, ToggleStatsRequestEvent,
};
use crate::font_plugin::FontResource;
use crate::profile_plugin::{AvatarResource, ProfileScreen};
use crate::progress_plugin::ProgressResource;
use crate::resources::SyncStatusResource;
use crate::settings_plugin::SettingsResource;
use crate::stats_plugin::{ReplayHistoryResource, SelectedReplayIndex, StatsResource, StatsScreen};
use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_focus::FocusRow;
use crate::ui_modal::{
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header, spawn_tab_chip,
};
use crate::ui_theme::{VAL_SPACE_2, VAL_SPACE_3, Z_MODAL_PANEL};
/// Which tab of the You hub is showing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum YouTab {
#[default]
Profile,
Stats,
Achievements,
Replays,
}
impl YouTab {
/// Every tab, in display order.
pub const ALL: [Self; 4] = [
Self::Profile,
Self::Stats,
Self::Achievements,
Self::Replays,
];
/// Chip label.
pub fn label(self) -> &'static str {
match self {
Self::Profile => "Profile",
Self::Stats => "Stats",
Self::Achievements => "Awards",
Self::Replays => "Replays",
}
}
}
/// The active hub tab. Session-only; reopening returns to the last tab
/// unless an open request names a different one.
#[derive(Resource, Debug, Default)]
pub struct ActiveYouTab(pub YouTab);
/// Marker on the hub modal's scrim root.
#[derive(Component)]
pub struct YouHubScreen;
/// Per-chip tab selector button.
#[derive(Component, Debug)]
struct YouHubTabButton(YouTab);
/// Marker on the hub's Done button.
#[derive(Component)]
struct YouHubCloseButton;
/// Read-only bundle of everything the tab bodies render from. Split
/// out as a [`SystemParam`] because the open + rebuild systems both
/// need the full set and Bevy caps systems at 16 parameters.
#[derive(SystemParam)]
struct YouHubContext<'w> {
settings: Option<Res<'w, SettingsResource>>,
sync_status: Option<Res<'w, SyncStatusResource>>,
progress: Option<Res<'w, ProgressResource>>,
achievements: Option<Res<'w, AchievementsResource>>,
stats: Option<Res<'w, StatsResource>>,
avatar: Option<Res<'w, AvatarResource>>,
time_attack: Option<Res<'w, TimeAttackResource>>,
replay_history: Option<Res<'w, ReplayHistoryResource>>,
selected_replay: Option<Res<'w, SelectedReplayIndex>>,
font_res: Option<Res<'w, FontResource>>,
}
/// Bevy plugin owning the You hub lifecycle. Requires the profile,
/// stats, and achievement plugins for live data; degrades to empty tab
/// bodies without them (headless tests).
pub struct YouHubPlugin;
impl Plugin for YouHubPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<ActiveYouTab>()
.add_message::<ToggleProfileRequestEvent>()
.add_message::<ToggleStatsRequestEvent>()
.add_message::<ToggleAchievementsRequestEvent>()
.add_systems(
Update,
// Chained: a toggle/chip press must be observed before
// the rebuild, and the rebuild before close — total
// order prevents double-spawns within one frame.
(
open_or_toggle_you_hub,
handle_tab_buttons,
rebuild_on_tab_change,
handle_close_button,
)
.chain(),
);
}
}
/// Maps this frame's toggle events + accelerator keys to a requested
/// tab, mirroring the semantics the three standalone screens had.
fn requested_tab(
profile_events: &mut MessageReader<ToggleProfileRequestEvent>,
stats_events: &mut MessageReader<ToggleStatsRequestEvent>,
achievements_events: &mut MessageReader<ToggleAchievementsRequestEvent>,
keys: &ButtonInput<KeyCode>,
) -> Option<YouTab> {
let profile = profile_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyP);
let stats = stats_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyS);
let achievements = achievements_events.read().count() > 0 || keys.just_pressed(KeyCode::KeyA);
if profile {
Some(YouTab::Profile)
} else if stats {
Some(YouTab::Stats)
} else if achievements {
Some(YouTab::Achievements)
} else {
None
}
}
/// Opens the hub on the requested tab, switches tabs when it's already
/// open on a different one, toggles it closed on a same-tab request or
/// Esc.
#[allow(clippy::too_many_arguments)]
fn open_or_toggle_you_hub(
mut commands: Commands,
keys: Res<ButtonInput<KeyCode>>,
mut profile_events: MessageReader<ToggleProfileRequestEvent>,
mut stats_events: MessageReader<ToggleStatsRequestEvent>,
mut achievements_events: MessageReader<ToggleAchievementsRequestEvent>,
screens: Query<Entity, With<YouHubScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<YouHubScreen>)>,
mut active: ResMut<ActiveYouTab>,
ctx: YouHubContext,
) {
let requested = requested_tab(
&mut profile_events,
&mut stats_events,
&mut achievements_events,
&keys,
);
let open = !screens.is_empty();
if open {
// Esc closes the topmost modal — the hub, when it's showing.
if keys.just_pressed(KeyCode::Escape) {
for entity in &screens {
commands.entity(entity).despawn();
}
return;
}
match requested {
Some(tab) if tab == active.0 => {
// Same-tab request toggles closed (P opens Profile,
// P again closes — parity with the old screens).
for entity in &screens {
commands.entity(entity).despawn();
}
}
Some(tab) => {
// Different tab: switch in place. The rebuild system
// (next in the chain) observes the change.
active.0 = tab;
}
None => {}
}
return;
}
let Some(tab) = requested else { return };
if !other_modal_scrims.is_empty() {
return; // Another modal is already visible (§14.2).
}
if active.0 != tab {
// Written before the spawn; the rebuild system also runs this
// frame but sees no live hub (the spawn below is deferred), so
// no double-spawn.
active.0 = tab;
}
spawn_you_hub(&mut commands, tab, &ctx);
}
/// Switches the active tab when a chip is pressed.
fn handle_tab_buttons(
interactions: Query<(&Interaction, &YouHubTabButton), Changed<Interaction>>,
mut active: ResMut<ActiveYouTab>,
) {
for (interaction, chip) in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
if active.0 != chip.0 {
active.0 = chip.0;
}
}
}
/// Rebuilds the open hub when [`ActiveYouTab`] changes.
fn rebuild_on_tab_change(
active: Res<ActiveYouTab>,
screens: Query<Entity, With<YouHubScreen>>,
mut commands: Commands,
ctx: YouHubContext,
) {
if !active.is_changed() || active.is_added() {
return;
}
if screens.is_empty() {
return;
}
for entity in &screens {
commands.entity(entity).despawn();
}
spawn_you_hub(&mut commands, active.0, &ctx);
}
/// Despawns the hub when Done is pressed.
fn handle_close_button(
interactions: Query<&Interaction, (Changed<Interaction>, With<YouHubCloseButton>)>,
screens: Query<Entity, With<YouHubScreen>>,
mut commands: Commands,
) {
for interaction in &interactions {
if *interaction != Interaction::Pressed {
continue;
}
for entity in &screens {
commands.entity(entity).despawn();
}
}
}
/// Spawns the hub modal showing `tab`, and stamps the scrim with the
/// tab's legacy screen marker so pre-hub queries keep working.
fn spawn_you_hub(commands: &mut Commands, tab: YouTab, ctx: &YouHubContext) {
let font_res = ctx.font_res.as_deref();
let scrim = spawn_modal(commands, YouHubScreen, Z_MODAL_PANEL, |card| {
spawn_modal_header(card, "You", font_res);
// Tab chips — shared widget with the Settings panel.
card.spawn((
FocusRow,
Node {
flex_direction: FlexDirection::Row,
flex_wrap: FlexWrap::Wrap,
column_gap: VAL_SPACE_2,
row_gap: VAL_SPACE_2,
margin: UiRect::bottom(VAL_SPACE_3),
..default()
},
))
.with_children(|row| {
for chip_tab in YouTab::ALL {
spawn_tab_chip(
row,
YouHubTabButton(chip_tab),
chip_tab.label(),
chip_tab == tab,
font_res,
);
}
});
match tab {
YouTab::Profile => crate::profile_plugin::spawn_profile_body(
card,
ctx.settings.as_deref(),
ctx.sync_status.as_deref(),
ctx.progress.as_deref(),
ctx.achievements.as_deref(),
ctx.stats.as_deref(),
font_res,
ctx.avatar.as_deref(),
),
YouTab::Stats => {
let default_stats = StatsSnapshot::default();
crate::stats_plugin::spawn_stats_body(
card,
ctx.stats.as_deref().map_or(&default_stats, |s| &s.0),
ctx.progress.as_deref().map(|p| &p.0),
ctx.time_attack.as_deref(),
font_res,
);
}
YouTab::Achievements => crate::achievement_plugin::spawn_achievements_body(
card,
ctx.achievements
.as_deref()
.map_or(&[][..], |a| a.0.as_slice()),
font_res,
),
YouTab::Replays => crate::stats_plugin::spawn_replays_body(
card,
ctx.replay_history
.as_deref()
.map_or(&[][..], |h| h.0.replays.as_slice()),
ctx.selected_replay.as_deref().map_or(0, |s| s.0),
font_res,
),
}
spawn_modal_actions(card, |actions| {
spawn_modal_button(
actions,
YouHubCloseButton,
"Done",
None,
ButtonVariant::Primary,
font_res,
);
});
});
let mut scrim_commands = commands.entity(scrim);
scrim_commands.insert(ScrimDismissible);
// Legacy markers: pre-hub code and tests query these.
match tab {
YouTab::Profile => {
scrim_commands.insert(ProfileScreen);
}
YouTab::Stats | YouTab::Replays => {
scrim_commands.insert(StatsScreen);
}
YouTab::Achievements => {
scrim_commands.insert(AchievementsScreen);
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
fn headless_app() -> App {
let mut app = App::new();
app.add_plugins(MinimalPlugins).add_plugins(YouHubPlugin);
app.init_resource::<ButtonInput<KeyCode>>();
app.update();
app
}
fn hub_count(app: &mut App) -> usize {
app.world_mut()
.query::<&YouHubScreen>()
.iter(app.world())
.count()
}
/// Each Toggle*RequestEvent opens the hub pre-selected to its tab,
/// stamped with the tab's legacy screen marker.
#[test]
fn toggle_events_open_hub_on_their_tab() {
use bevy::ecs::message::Messages;
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleStatsRequestEvent>>()
.write(ToggleStatsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "stats request must open the hub");
assert_eq!(app.world().resource::<ActiveYouTab>().0, YouTab::Stats);
assert_eq!(
app.world_mut()
.query::<&StatsScreen>()
.iter(app.world())
.count(),
1,
"legacy StatsScreen marker must ride the hub scrim"
);
// A different tab's request switches in place — still one hub.
app.world_mut()
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
.write(ToggleAchievementsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "tab switch must not stack hubs");
assert_eq!(
app.world().resource::<ActiveYouTab>().0,
YouTab::Achievements
);
assert_eq!(
app.world_mut()
.query::<&AchievementsScreen>()
.iter(app.world())
.count(),
1
);
// Same-tab request toggles the hub closed.
app.world_mut()
.resource_mut::<Messages<ToggleAchievementsRequestEvent>>()
.write(ToggleAchievementsRequestEvent);
app.update();
app.update();
assert_eq!(hub_count(&mut app), 0, "same-tab request must close");
}
/// Chip-driven tab switches rebuild the single hub with the new
/// tab's body (Profile scrollable swaps for the Stats one).
#[test]
fn tab_switch_swaps_bodies_without_stacking() {
use crate::profile_plugin::ProfileScrollable;
use crate::stats_plugin::StatsScrollable;
use bevy::ecs::message::Messages;
let mut app = headless_app();
app.world_mut()
.resource_mut::<Messages<ToggleProfileRequestEvent>>()
.write(ToggleProfileRequestEvent);
app.update();
app.update();
assert_eq!(
app.world_mut()
.query::<&ProfileScrollable>()
.iter(app.world())
.count(),
1,
"profile body must spawn on the Profile tab"
);
app.world_mut().resource_mut::<ActiveYouTab>().0 = YouTab::Stats;
app.update();
app.update();
assert_eq!(hub_count(&mut app), 1, "rebuild must not stack scrims");
assert_eq!(
app.world_mut()
.query::<&ProfileScrollable>()
.iter(app.world())
.count(),
0,
"profile body must despawn when leaving the tab"
);
assert_eq!(
app.world_mut()
.query::<&StatsScrollable>()
.iter(app.world())
.count(),
1,
"stats body must spawn on the Stats tab"
);
}
}
+12 -12
View File
@@ -1649,62 +1649,62 @@ function __wbg_get_imports() {
return ret;
},
__wbindgen_cast_0000000000000001: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62000, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 62031, ret: Result(Unit), inner_ret: Some(Result(Unit)) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hd94d76233321402f);
return ret;
},
__wbindgen_cast_0000000000000002: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Externref], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354);
return ret;
},
__wbindgen_cast_0000000000000003: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7446, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>"), NamedExternref("ResizeObserver")], shim_idx: 7477, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfc779804ccb0943e);
return ret;
},
__wbindgen_cast_0000000000000004: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Array<any>")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_3);
return ret;
},
__wbindgen_cast_0000000000000005: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("Event")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_4);
return ret;
},
__wbindgen_cast_0000000000000006: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("FocusEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_5);
return ret;
},
__wbindgen_cast_0000000000000007: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("KeyboardEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_6);
return ret;
},
__wbindgen_cast_0000000000000008: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PageTransitionEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_7);
return ret;
},
__wbindgen_cast_0000000000000009: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("PointerEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_8);
return ret;
},
__wbindgen_cast_000000000000000a: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7443, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [NamedExternref("WheelEvent")], shim_idx: 7474, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__h26ff63c654218354_9);
return ret;
},
__wbindgen_cast_000000000000000b: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7444, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [Option(NamedExternref("Blob"))], shim_idx: 7475, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hfd77696cd35180b1);
return ret;
},
__wbindgen_cast_000000000000000c: function(arg0, arg1) {
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7445, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
// Cast intrinsic for `Closure(Closure { owned: true, function: Function { arguments: [], shim_idx: 7476, ret: Unit, inner_ret: Some(Unit) }, mutable: true }) -> Externref`.
const ret = makeMutClosure(arg0, arg1, wasm_bindgen__convert__closures_____invoke__hebcd362fbbe0fc8c);
return ret;
},
Binary file not shown.
Binary file not shown.
-5
View File
@@ -228,11 +228,6 @@ impl ReplayPlayer {
pub fn step_idx(&self) -> usize {
self.step_idx
}
/// Returns `true` once every move has been applied.
pub fn is_finished(&self) -> bool {
self.step_idx >= self.moves.len()
}
}
// ---------------------------------------------------------------------------