Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36cbf6df58 | |||
| 4e18b18122 | |||
| e22970f1ef | |||
| 020c860cc5 | |||
| 2f373784bf | |||
| b4845564af | |||
| 838342649c | |||
| 7f604f5df6 | |||
| 099b7a263f | |||
| a0776f79b1 | |||
| 4b484f899a | |||
| c2256582cd | |||
| b405e54bb0 | |||
| 5c97913868 | |||
| c8fc3e7a09 | |||
| 7c2daa1e32 | |||
| 37919cfe17 | |||
| 9081a9916c | |||
| 5430d4c3ab | |||
| fc4ea17789 | |||
| 02bcc8b4af | |||
| d4d0bde0c0 | |||
| cbbab3d09f | |||
| b2581024f3 |
@@ -1,4 +1,4 @@
|
|||||||
# Build and deploy the solitaire server Docker image.
|
# Build and deploy the solitaire server Docker image. (retriggered 2026-07-16)
|
||||||
name: Build and Deploy
|
name: Build and Deploy
|
||||||
|
|
||||||
on:
|
on:
|
||||||
@@ -15,6 +15,9 @@ on:
|
|||||||
- 'Cargo.toml'
|
- 'Cargo.toml'
|
||||||
- 'Cargo.lock'
|
- 'Cargo.lock'
|
||||||
- 'build_wasm.sh'
|
- 'build_wasm.sh'
|
||||||
|
# The engine embeds CHANGELOG.md (What's-new card) — changelog cuts
|
||||||
|
# must redeploy the web so the card content stays fresh.
|
||||||
|
- 'CHANGELOG.md'
|
||||||
- 'solitaire_server/Dockerfile'
|
- 'solitaire_server/Dockerfile'
|
||||||
- '.gitea/workflows/docker-build.yml'
|
- '.gitea/workflows/docker-build.yml'
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,86 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.49.0] — 2026-07-16
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **See what sync did.** After every sync, the Account tab shows exactly
|
||||||
|
what the merge decided — a "no conflicts" note, or one line per
|
||||||
|
reconciled field with both values ("this device: 3 / server: 5"). When
|
||||||
|
a merge wasn't clean, a toast summarises it: "Synced — 2 conflicts,
|
||||||
|
kept newer values". (#189)
|
||||||
|
- **One-file backup.** Settings → Account → Local data can now export
|
||||||
|
every save (settings, stats, achievements, progress) into a single
|
||||||
|
backup file next to your other saves — and restore from it later or on
|
||||||
|
another machine. Restores never silently reset anything: a missing or
|
||||||
|
incompatible backup shows an error and leaves your data untouched.
|
||||||
|
(#189)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Under the hood, the scheduler-ambiguity gate now covers the full
|
||||||
|
plugin cluster (22 plugins) — 585 undefined-order system pairs were
|
||||||
|
pinned or proven independent, eliminating a whole class of
|
||||||
|
"works-on-my-frame" bugs in modes, replays, input, and chrome. (#188)
|
||||||
|
|
||||||
|
## [0.48.1] — 2026-07-15
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Reduce Transparency.** Settings → Accessibility gains a toggle that
|
||||||
|
replaces the see-through glass toolbar with a solid panel, so cards
|
||||||
|
behind it can never reduce readability. High Contrast mode now also
|
||||||
|
makes the glass markedly more opaque with a brighter outline. (#187)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **The toolbar gets out of your way.** While you're dragging a card,
|
||||||
|
the floating toolbar slides off the bottom edge and returns when you
|
||||||
|
drop — bottom-row piles are never hidden behind it. Instant (no
|
||||||
|
animation) when Reduce Motion is on. (#187)
|
||||||
|
|
||||||
|
## [0.48.0] — 2026-07-15
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Floating glass tab bar** (touch). The bottom action bar is now a
|
||||||
|
floating translucent pill — Undo, Draw, Hint, and Pause — with a
|
||||||
|
separate round Menu button beside it, hovering above the bottom edge
|
||||||
|
instead of docking to it. Draw stays expanded as the highlighted
|
||||||
|
action; the other buttons reveal their label while held (instantly,
|
||||||
|
with reduce-motion on). Same actions, same behaviour — new clothes.
|
||||||
|
(#186)
|
||||||
|
- **Consistent empty, loading, and error screens.** The theme store,
|
||||||
|
leaderboard, and replay list now share one visual language for
|
||||||
|
"nothing here yet", "still loading", and "something went wrong" —
|
||||||
|
each with a next step instead of a dead end. (#184)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Hotkeys during drags.** Pressing Undo, New Game, Draw, or a mode
|
||||||
|
key mid-drag no longer fights the card you're holding — game-mutating
|
||||||
|
hotkeys are ignored until the card is dropped. (#185)
|
||||||
|
|
||||||
|
## [0.47.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **"What's new" on update.** The first launch after an update shows a
|
||||||
|
one-time card summarising what changed — like this one, right now.
|
||||||
|
Dismiss it and it never repeats; fresh installs never see it. (#180)
|
||||||
|
- **Situational tips.** Two one-time teaches that fire from play, not a
|
||||||
|
tour: staring at a stuck board for a while points you at Hint, and an
|
||||||
|
engaged game quietly mentions the long-press quick-action menu —
|
||||||
|
unless you've already found it yourself. (#181)
|
||||||
|
- **UI scale.** Settings → Accessibility now has a UI Scale control
|
||||||
|
(90% / 100% / 115% / 130%) that resizes every menu, button, and HUD
|
||||||
|
element live. The table always fits your screen regardless. (#182)
|
||||||
|
- **Hold `/` for shortcuts** (desktop). A reference card of every
|
||||||
|
keyboard binding appears while `/` is held. The onboarding tour and
|
||||||
|
this card now share one binding table, so they can never disagree —
|
||||||
|
the tour's copy had drifted from reality. (#183)
|
||||||
|
|
||||||
## [0.46.0] — 2026-07-13
|
## [0.46.0] — 2026-07-13
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -490,6 +490,32 @@ impl GameState {
|
|||||||
self.session.stats().stats().recycle_count()
|
self.session.stats().stats().recycle_count()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Number of cards moved onto foundations this game, read from the
|
||||||
|
/// upstream session stats. Cumulative like [`Self::recycle_count`] —
|
||||||
|
/// not rolled back on undo.
|
||||||
|
pub fn move_to_foundation_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().move_to_foundation_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of stacks moved onto tableaus (from stock or another tableau)
|
||||||
|
/// this game, read from the upstream session stats. Cumulative — not
|
||||||
|
/// rolled back on undo.
|
||||||
|
pub fn move_to_tableau_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().move_to_tableau_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of cards taken back off a foundation this game, read from the
|
||||||
|
/// upstream session stats. Cumulative — not rolled back on undo.
|
||||||
|
pub fn move_from_foundation_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().move_from_foundation_count()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Number of face-down cards revealed (flipped up) this game, read from
|
||||||
|
/// the upstream session stats. Cumulative — not rolled back on undo.
|
||||||
|
pub fn flip_up_count(&self) -> u32 {
|
||||||
|
self.session.stats().stats().flip_up_bonus_count()
|
||||||
|
}
|
||||||
|
|
||||||
/// Total moves made this game (draws, recycles, and card moves), derived
|
/// Total moves made this game (draws, recycles, and card moves), derived
|
||||||
/// from the session's instruction history length.
|
/// from the session's instruction history length.
|
||||||
pub fn move_count(&self) -> u32 {
|
pub fn move_count(&self) -> u32 {
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ pub use challenge::{CHALLENGE_SEEDS, challenge_count, challenge_seed_for};
|
|||||||
pub mod difficulty_seeds;
|
pub mod difficulty_seeds;
|
||||||
pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
pub use difficulty_seeds::{DifficultySeeds, seeds_for};
|
||||||
|
|
||||||
|
pub mod transfer;
|
||||||
|
pub use transfer::{
|
||||||
|
BundleError, DATA_BUNDLE_SCHEMA_VERSION, DataBundle, data_bundle_path, load_bundle_from,
|
||||||
|
save_bundle_to,
|
||||||
|
};
|
||||||
|
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub use settings::{
|
pub use settings::{
|
||||||
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
AnimSpeed, REPLAY_MOVE_INTERVAL_STEP_SECS, SOLVER_DEAL_RETRY_CAP, Settings, SyncBackend,
|
||||||
|
|||||||
@@ -154,6 +154,14 @@ pub struct Settings {
|
|||||||
/// `#[serde(default)]`.
|
/// `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub reduce_motion_mode: bool,
|
pub reduce_motion_mode: bool,
|
||||||
|
/// When `true`, replaces translucent "glass" chrome (the floating
|
||||||
|
/// touch tab bar) with opaque fills so table content behind it can
|
||||||
|
/// never reduce label/icon contrast. The accessibility counterpart
|
||||||
|
/// of iOS "Reduce Transparency". Older `settings.json` files written
|
||||||
|
/// before this field existed deserialize cleanly to `false` thanks
|
||||||
|
/// to `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub reduce_transparency_mode: bool,
|
||||||
/// Window size and screen position to restore on next launch. `None`
|
/// Window size and screen position to restore on next launch. `None`
|
||||||
/// means "use platform defaults" — set on first run, then populated
|
/// means "use platform defaults" — set on first run, then populated
|
||||||
/// as the player resizes / moves the window. Older `settings.json`
|
/// as the player resizes / moves the window. Older `settings.json`
|
||||||
@@ -451,6 +459,7 @@ impl Default for Settings {
|
|||||||
color_blind_mode: false,
|
color_blind_mode: false,
|
||||||
high_contrast_mode: false,
|
high_contrast_mode: false,
|
||||||
reduce_motion_mode: false,
|
reduce_motion_mode: false,
|
||||||
|
reduce_transparency_mode: false,
|
||||||
window_geometry: None,
|
window_geometry: None,
|
||||||
selected_theme_id: default_theme_id(),
|
selected_theme_id: default_theme_id(),
|
||||||
shown_achievement_onboarding: false,
|
shown_achievement_onboarding: false,
|
||||||
|
|||||||
@@ -0,0 +1,137 @@
|
|||||||
|
//! Local data export/import (Phase M — sync transparency).
|
||||||
|
//!
|
||||||
|
//! Bundles every player-owned JSON save (settings, stats, achievements,
|
||||||
|
//! progress) into one versioned backup file so a player can move their
|
||||||
|
//! data between devices without an account, or keep a manual backup.
|
||||||
|
//!
|
||||||
|
//! The bundle deliberately excludes the in-progress game and the replay
|
||||||
|
//! history: the former is transient, the latter can be large and is
|
||||||
|
//! shareable through the replay upload path instead.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::io;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::settings::Settings;
|
||||||
|
use solitaire_sync::{AchievementRecord, PlayerProgress, StatsSnapshot};
|
||||||
|
|
||||||
|
/// Bundle format version. Bump on any breaking shape change; the loader
|
||||||
|
/// rejects newer versions rather than misreading them.
|
||||||
|
pub const DATA_BUNDLE_SCHEMA_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
/// File name of the default backup location (next to the other saves).
|
||||||
|
pub const DATA_BUNDLE_FILE_NAME: &str = "ferrous-solitaire-backup.json";
|
||||||
|
|
||||||
|
/// One-file backup of every player-owned JSON save.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DataBundle {
|
||||||
|
/// Format version — see [`DATA_BUNDLE_SCHEMA_VERSION`].
|
||||||
|
pub schema_version: u32,
|
||||||
|
/// When the bundle was written.
|
||||||
|
pub exported_at: DateTime<Utc>,
|
||||||
|
pub settings: Settings,
|
||||||
|
pub stats: StatsSnapshot,
|
||||||
|
pub achievements: Vec<AchievementRecord>,
|
||||||
|
pub progress: PlayerProgress,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a bundle could not be loaded.
|
||||||
|
#[derive(Debug, thiserror::Error)]
|
||||||
|
pub enum BundleError {
|
||||||
|
#[error("could not read backup file: {0}")]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
#[error("backup file is not valid JSON: {0}")]
|
||||||
|
Parse(#[from] serde_json::Error),
|
||||||
|
#[error("backup was written by a newer app version (schema {found} > {supported})")]
|
||||||
|
UnsupportedVersion { found: u32, supported: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default backup path: `<data dir>/<app dir>/ferrous-solitaire-backup.json`,
|
||||||
|
/// or `None` when no data directory is available on this platform.
|
||||||
|
pub fn data_bundle_path() -> Option<PathBuf> {
|
||||||
|
crate::data_dir().map(|d| d.join(crate::APP_DIR_NAME).join(DATA_BUNDLE_FILE_NAME))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write `bundle` to `path` atomically (`.tmp` → rename), creating parent
|
||||||
|
/// directories as needed. Mirrors the other save-file writers.
|
||||||
|
pub fn save_bundle_to(path: &Path, bundle: &DataBundle) -> io::Result<()> {
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(bundle).map_err(io::Error::other)?;
|
||||||
|
let tmp = path.with_extension("json.tmp");
|
||||||
|
fs::write(&tmp, json.as_bytes())?;
|
||||||
|
fs::rename(&tmp, path)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and validate a bundle from `path`. Unlike the per-file loaders this
|
||||||
|
/// does NOT default-on-error — an import silently replacing player data
|
||||||
|
/// with defaults would be destructive, so every failure is surfaced.
|
||||||
|
pub fn load_bundle_from(path: &Path) -> Result<DataBundle, BundleError> {
|
||||||
|
let data = fs::read(path)?;
|
||||||
|
let bundle: DataBundle = serde_json::from_slice(&data)?;
|
||||||
|
if bundle.schema_version > DATA_BUNDLE_SCHEMA_VERSION {
|
||||||
|
return Err(BundleError::UnsupportedVersion {
|
||||||
|
found: bundle.schema_version,
|
||||||
|
supported: DATA_BUNDLE_SCHEMA_VERSION,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(bundle)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn sample_bundle() -> DataBundle {
|
||||||
|
DataBundle {
|
||||||
|
schema_version: DATA_BUNDLE_SCHEMA_VERSION,
|
||||||
|
exported_at: Utc::now(),
|
||||||
|
settings: Settings::default(),
|
||||||
|
stats: StatsSnapshot {
|
||||||
|
games_played: 7,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
achievements: vec![],
|
||||||
|
progress: PlayerProgress::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_through_disk() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bundle_test_{}", std::process::id()));
|
||||||
|
let path = dir.join(DATA_BUNDLE_FILE_NAME);
|
||||||
|
let bundle = sample_bundle();
|
||||||
|
save_bundle_to(&path, &bundle).expect("save should succeed");
|
||||||
|
let loaded = load_bundle_from(&path).expect("load should succeed");
|
||||||
|
assert_eq!(loaded.schema_version, DATA_BUNDLE_SCHEMA_VERSION);
|
||||||
|
assert_eq!(loaded.stats.games_played, 7);
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_newer_schema() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("bundle_test_v_{}", std::process::id()));
|
||||||
|
let path = dir.join(DATA_BUNDLE_FILE_NAME);
|
||||||
|
let mut bundle = sample_bundle();
|
||||||
|
bundle.schema_version = DATA_BUNDLE_SCHEMA_VERSION + 1;
|
||||||
|
save_bundle_to(&path, &bundle).expect("save should succeed");
|
||||||
|
assert!(matches!(
|
||||||
|
load_bundle_from(&path),
|
||||||
|
Err(BundleError::UnsupportedVersion { .. })
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_file_is_an_io_error() {
|
||||||
|
assert!(matches!(
|
||||||
|
load_bundle_from(Path::new("/nonexistent/backup.json")),
|
||||||
|
Err(BundleError::Io(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,6 +67,16 @@ pub struct AchievementsResource(pub Vec<AchievementRecord>);
|
|||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct AchievementsStoragePath(pub Option<PathBuf>);
|
pub struct AchievementsStoragePath(pub Option<PathBuf>);
|
||||||
|
|
||||||
|
/// Set wrapping the win-path achievement evaluator. It writes
|
||||||
|
/// `AchievementsResource`/`ProgressResource` and emits unlock events; UI
|
||||||
|
/// readers of those (HUD popovers, the stats/achievements panels) order
|
||||||
|
/// themselves `.after(AchievementEval)`. The replay-completion evaluator is
|
||||||
|
/// deliberately NOT a member: it runs after `AutoComplete`, and panel chains
|
||||||
|
/// that need `.after(AchievementEval)` run before `AutoComplete` — putting
|
||||||
|
/// both in one set would close an ordering cycle (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct AchievementEval;
|
||||||
|
|
||||||
pub struct AchievementPlugin {
|
pub struct AchievementPlugin {
|
||||||
pub storage_path: Option<PathBuf>,
|
pub storage_path: Option<PathBuf>,
|
||||||
}
|
}
|
||||||
@@ -119,9 +129,13 @@ impl Plugin for AchievementPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
evaluate_on_win
|
evaluate_on_win
|
||||||
|
.in_set(AchievementEval)
|
||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
.after(StatsUpdate)
|
.after(StatsUpdate)
|
||||||
.after(ProgressUpdate),
|
.after(ProgressUpdate)
|
||||||
|
// The unlock/XP/level-up events it emits are drained by
|
||||||
|
// the toast pipeline this frame (#143).
|
||||||
|
.before(crate::animation_plugin::AnimFx),
|
||||||
)
|
)
|
||||||
// Achievement-onboarding cue: fires once after the player's very
|
// Achievement-onboarding cue: fires once after the player's very
|
||||||
// first win to teach the Achievements panel exists. Must run
|
// first win to teach the Achievements panel exists. Must run
|
||||||
@@ -131,20 +145,50 @@ impl Plugin for AchievementPlugin {
|
|||||||
Update,
|
Update,
|
||||||
fire_achievement_onboarding_toast
|
fire_achievement_onboarding_toast
|
||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
.after(StatsUpdate),
|
.after(StatsUpdate)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters)
|
||||||
|
// Writes the one-shot onboarding flag into
|
||||||
|
// SettingsResource: board painters re-read settings, so
|
||||||
|
// land the write first; UiTextFx members read disjoint
|
||||||
|
// settings fields (reduce-motion), so writer/reader
|
||||||
|
// order there is provably irrelevant (#143).
|
||||||
|
.before(crate::card_plugin::BoardVisuals)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
// Open/close/tab handling moved to `you_hub_plugin`
|
// Open/close/tab handling moved to `you_hub_plugin`
|
||||||
// (Phase E) — this plugin now owns body content + scroll.
|
// (Phase E) — this plugin now owns body content + scroll.
|
||||||
.add_systems(Update, scroll_achievements_panel)
|
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>,
|
scroll_achievements_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
crate::ui_modal::touch_scroll_panel::<AchievementsScrollable>
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
)
|
)
|
||||||
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
// Event-driven unlock: observe `ReplayPlaybackState` and unlock
|
||||||
// `cinephile` the first time playback runs to natural completion.
|
// `cinephile` the first time playback runs to natural completion.
|
||||||
// Reads the resource via `Option<Res<_>>` so headless tests that
|
// Reads the resource via `Option<Res<_>>` so headless tests that
|
||||||
// omit `ReplayPlaybackPlugin` still build.
|
// omit `ReplayPlaybackPlugin` still build.
|
||||||
.add_systems(Update, evaluate_cinephile_on_replay_completion);
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
// Shares the unlock stream and AchievementsResource with
|
||||||
|
// evaluate_on_win; ordered after it and before the toast
|
||||||
|
// pipeline that drains the unlock events (#143).
|
||||||
|
evaluate_cinephile_on_replay_completion
|
||||||
|
.after(evaluate_on_win)
|
||||||
|
// The stats overlay's watch-replay button (ordered
|
||||||
|
// before AutoComplete) writes ReplayPlaybackState this
|
||||||
|
// system reads.
|
||||||
|
.after(crate::auto_complete_plugin::AutoComplete)
|
||||||
|
.before(crate::animation_plugin::AnimFx),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -179,10 +179,13 @@ pub struct ToastStackRoot;
|
|||||||
pub struct ToastNode;
|
pub struct ToastNode;
|
||||||
|
|
||||||
/// Logical-pixel gap between the screen bottom and the toast stack,
|
/// Logical-pixel gap between the screen bottom and the toast stack,
|
||||||
/// before safe-area insets. Clears the Phase F bottom action bar on
|
/// before safe-area insets. Clears the floating glass tab bar on touch
|
||||||
/// touch (compact 44px buttons + primary 64px trio + bar padding);
|
/// (its margin + height + breathing room); desktop's docked bar needs less.
|
||||||
/// desktop's shorter bar needs less.
|
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT {
|
||||||
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
|
crate::hud_plugin::tab_bar::TAB_BAR_CLEARANCE_PX
|
||||||
|
} else {
|
||||||
|
72.0
|
||||||
|
};
|
||||||
|
|
||||||
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
||||||
fn spawn_toast_stack_root(mut commands: Commands) {
|
fn spawn_toast_stack_root(mut commands: Commands) {
|
||||||
@@ -227,6 +230,14 @@ fn adopt_toasts_into_stack(
|
|||||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||||
pub struct AnimationPlugin;
|
pub struct AnimationPlugin;
|
||||||
|
|
||||||
|
/// Set wrapping this plugin's Update chain (card slide/cascade animation and
|
||||||
|
/// the toast pipeline). Ordered after [`crate::card_animation::CardTweenFx`]
|
||||||
|
/// on the shared visual spine — members write card `Transform`s, which every
|
||||||
|
/// other painter set also touches — and after the toast/stat/progress writer
|
||||||
|
/// sets so the toast handlers drain this frame's events (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct AnimFx;
|
||||||
|
|
||||||
impl Plugin for AnimationPlugin {
|
impl Plugin for AnimationPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register the events this plugin consumes so tests that don't include
|
// Register the events this plugin consumes so tests that don't include
|
||||||
@@ -250,12 +261,36 @@ impl Plugin for AnimationPlugin {
|
|||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
AnimFx
|
||||||
|
// Visual spine: …BoardVisuals < MarkerVisuals <
|
||||||
|
// CardTweenFx < AnimFx < FeedbackFx (#143). GameMutation
|
||||||
|
// ordering is inherited transitively.
|
||||||
|
.after(crate::card_animation::CardTweenFx)
|
||||||
|
.after(crate::table_plugin::MarkerVisuals)
|
||||||
|
// Stats/progress must settle before the win/level-up
|
||||||
|
// toast handlers read their events. (No edge against
|
||||||
|
// InfoToastWriters: toast writers exist on both sides
|
||||||
|
// of this set — enqueue_toasts drains via cursor and
|
||||||
|
// joins the club instead; a toast surfacing one frame
|
||||||
|
// later is invisible.)
|
||||||
|
.after(crate::stats_plugin::StatsUpdate)
|
||||||
|
.after(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.after(GameMutation)
|
||||||
|
// HUD text pulses are disjoint from the card entities
|
||||||
|
// this set animates (see UiTextFx × BoardVisuals, #143).
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
advance_card_anims,
|
advance_card_anims,
|
||||||
sync_slide_duration,
|
// Both drain SettingsChangedEvent / read anim-speed
|
||||||
handle_win_cascade,
|
// fields via cursor; a home deal-option edit surfacing
|
||||||
|
// one frame later is invisible (#143).
|
||||||
|
sync_slide_duration.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
handle_win_cascade.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
handle_achievement_toast,
|
handle_achievement_toast,
|
||||||
handle_levelup_toast,
|
handle_levelup_toast,
|
||||||
handle_daily_goal_announcement_toast,
|
handle_daily_goal_announcement_toast,
|
||||||
@@ -263,16 +298,23 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_weekly_toast,
|
handle_weekly_toast,
|
||||||
handle_time_attack_toast,
|
handle_time_attack_toast,
|
||||||
handle_challenge_toast,
|
handle_challenge_toast,
|
||||||
handle_settings_toast,
|
handle_settings_toast.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
handle_auto_complete_toast,
|
handle_auto_complete_toast,
|
||||||
handle_xp_awarded_toast,
|
handle_xp_awarded_toast,
|
||||||
handle_move_rejected_toast,
|
handle_move_rejected_toast,
|
||||||
handle_warning_toast,
|
handle_warning_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(
|
||||||
|
enqueue_toasts
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
drive_toast_display,
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
adopt_toasts_into_stack,
|
adopt_toasts_into_stack,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.chain()
|
||||||
|
.in_set(AnimFx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
//! SVG builders for the floating tab bar's action icons.
|
||||||
|
//!
|
||||||
|
//! The touch tab bar (`hud_plugin::tab_bar`) is icon-first, but the bundled
|
||||||
|
//! FiraMono face has no icon glyphs (and the Geometric Shapes block U+25xx
|
||||||
|
//! is missing on Android entirely), so the icons are authored here as tiny
|
||||||
|
//! stroke-based SVGs and rasterised through the same `usvg`/`resvg` pipeline
|
||||||
|
//! as the card themes (`assets::rasterize_svg`).
|
||||||
|
//!
|
||||||
|
//! Icons are drawn in **white** on a transparent background inside a
|
||||||
|
//! 24-unit logical box; the spawn site tints them to the theme colour via
|
||||||
|
//! `ImageNode::color` (a per-node multiply), so one texture serves every
|
||||||
|
//! button state.
|
||||||
|
|
||||||
|
use bevy::image::Image;
|
||||||
|
use bevy::math::UVec2;
|
||||||
|
|
||||||
|
use super::svg_loader::{SvgLoaderError, rasterize_svg};
|
||||||
|
|
||||||
|
/// Rasterisation size for one tab-bar icon texture. Icons display at
|
||||||
|
/// 24 logical px; 96 px texels keep them crisp at the Fold's 1.2 scale
|
||||||
|
/// factor (and any future 3× phone) while staying trivially small.
|
||||||
|
pub const HUD_ICON_TEXTURE_PX: u32 = 96;
|
||||||
|
|
||||||
|
/// The five actions the floating touch tab bar can show.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum HudIcon {
|
||||||
|
/// Counter-clockwise "take back" arrow.
|
||||||
|
Undo,
|
||||||
|
/// Two offset card outlines — draw from the stock.
|
||||||
|
Draw,
|
||||||
|
/// Lightbulb — highlight a suggested move.
|
||||||
|
Hint,
|
||||||
|
/// Two vertical bars.
|
||||||
|
Pause,
|
||||||
|
/// Three-line hamburger — the floating menu button.
|
||||||
|
Menu,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HudIcon {
|
||||||
|
/// Build the icon's SVG document. All icons share one visual language:
|
||||||
|
/// 2-unit white strokes, round caps, transparent fill, 24-unit box.
|
||||||
|
pub fn svg(self) -> String {
|
||||||
|
let body = match self {
|
||||||
|
// Arrow head pointing left into a half-circle sweeping back
|
||||||
|
// over the top — the conventional undo mark.
|
||||||
|
HudIcon::Undo => concat!(
|
||||||
|
r##"<path d="M 8 5 L 4 9 L 8 13"/>"##,
|
||||||
|
r##"<path d="M 4 9 H 14 A 5.5 5.5 0 0 1 14 20 H 8"/>"##,
|
||||||
|
),
|
||||||
|
// Back card peeks out behind the front card; the front card is
|
||||||
|
// drawn second so its stroke reads on top.
|
||||||
|
HudIcon::Draw => concat!(
|
||||||
|
r##"<rect x="8.5" y="3" width="11" height="15" rx="2"/>"##,
|
||||||
|
r##"<rect x="4" y="6.5" width="11" height="15" rx="2"/>"##,
|
||||||
|
),
|
||||||
|
// Bulb dome with a flat lamp base beneath.
|
||||||
|
HudIcon::Hint => concat!(
|
||||||
|
r##"<path d="M 9 15.5 A 6.5 6.5 0 1 1 15 15.5 V 18 H 9 Z"/>"##,
|
||||||
|
r##"<path d="M 10 21 H 14"/>"##,
|
||||||
|
),
|
||||||
|
HudIcon::Pause => concat!(
|
||||||
|
r##"<path d="M 9 6 V 18"/>"##,
|
||||||
|
r##"<path d="M 15 6 V 18"/>"##,
|
||||||
|
),
|
||||||
|
HudIcon::Menu => concat!(
|
||||||
|
r##"<path d="M 4 6.5 H 20"/>"##,
|
||||||
|
r##"<path d="M 4 12 H 20"/>"##,
|
||||||
|
r##"<path d="M 4 17.5 H 20"/>"##,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
concat!(
|
||||||
|
r##"<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">"##,
|
||||||
|
r##"<g fill="none" stroke="#ffffff" stroke-width="2" "##,
|
||||||
|
r##"stroke-linecap="round" stroke-linejoin="round">{body}</g>"##,
|
||||||
|
r##"</svg>"##,
|
||||||
|
),
|
||||||
|
body = body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterise this icon into a Bevy [`Image`] at
|
||||||
|
/// [`HUD_ICON_TEXTURE_PX`] square.
|
||||||
|
pub fn rasterize(self) -> Result<Image, SvgLoaderError> {
|
||||||
|
rasterize_svg(self.svg().as_bytes(), UVec2::splat(HUD_ICON_TEXTURE_PX))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const ALL: [HudIcon; 5] = [
|
||||||
|
HudIcon::Undo,
|
||||||
|
HudIcon::Draw,
|
||||||
|
HudIcon::Hint,
|
||||||
|
HudIcon::Pause,
|
||||||
|
HudIcon::Menu,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Every icon must survive the real usvg/resvg pipeline at the real
|
||||||
|
/// texture size — a malformed path here would otherwise only surface
|
||||||
|
/// as a blank button on device.
|
||||||
|
#[test]
|
||||||
|
fn every_icon_rasterizes_at_texture_size() {
|
||||||
|
for icon in ALL {
|
||||||
|
let img = icon.rasterize().expect("icon SVG must rasterise");
|
||||||
|
assert_eq!(img.width(), HUD_ICON_TEXTURE_PX, "{icon:?} width");
|
||||||
|
assert_eq!(img.height(), HUD_ICON_TEXTURE_PX, "{icon:?} height");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rasterised icons must contain visible (non-transparent) pixels —
|
||||||
|
/// guards against a stroke accidentally drawn outside the viewBox.
|
||||||
|
#[test]
|
||||||
|
fn every_icon_has_visible_pixels() {
|
||||||
|
for icon in ALL {
|
||||||
|
let img = icon.rasterize().expect("icon SVG must rasterise");
|
||||||
|
let data = img.data.as_ref().expect("rasterised image has data");
|
||||||
|
let opaque = data.chunks_exact(4).filter(|px| px[3] > 0).count();
|
||||||
|
assert!(
|
||||||
|
opaque > 100,
|
||||||
|
"{icon:?} renders only {opaque} visible pixels"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
//! custom `AssetSource` implementations used by the theme system.
|
//! custom `AssetSource` implementations used by the theme system.
|
||||||
|
|
||||||
pub mod card_face_svg;
|
pub mod card_face_svg;
|
||||||
|
pub mod hud_icon_svg;
|
||||||
pub mod icon_svg;
|
pub mod icon_svg;
|
||||||
pub mod sources;
|
pub mod sources;
|
||||||
pub mod svg_loader;
|
pub mod svg_loader;
|
||||||
|
|||||||
@@ -83,6 +83,14 @@ use tuning::update_input_platform;
|
|||||||
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
|
/// long as no single entity carries both `CardAnim` and `CardAnimation`.
|
||||||
pub struct CardAnimationPlugin;
|
pub struct CardAnimationPlugin;
|
||||||
|
|
||||||
|
/// Set wrapping this plugin's Update chain (curve tweens, hover/drag
|
||||||
|
/// visuals). First anim stage on the visual spine: …BoardVisuals <
|
||||||
|
/// MarkerVisuals < CardTweenFx < [`crate::animation_plugin::AnimFx`] <
|
||||||
|
/// FeedbackFx — members write card `Transform`s, which every other painter
|
||||||
|
/// set also touches (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct CardTweenFx;
|
||||||
|
|
||||||
impl Plugin for CardAnimationPlugin {
|
impl Plugin for CardAnimationPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register events and resources idempotently — double-registration is
|
// Register events and resources idempotently — double-registration is
|
||||||
@@ -115,7 +123,14 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
// still present in the same frame it was removed (deferred
|
// still present in the same frame it was removed (deferred
|
||||||
// commands aren't applied until the next ApplyDeferred
|
// commands aren't applied until the next ApplyDeferred
|
||||||
// point), causing a 1-frame gap between every chain step.
|
// point), causing a 1-frame gap between every chain step.
|
||||||
ApplyDeferred,
|
//
|
||||||
|
// `ambiguous_with_all`: an explicit sync point conflicts
|
||||||
|
// (on `World`) with every unordered system in the
|
||||||
|
// schedule. Only the flush of THIS chain's commands needs
|
||||||
|
// ordering — the chain edges provide that; whether an
|
||||||
|
// unrelated system's commands flush here or at the next
|
||||||
|
// sync point is immaterial to it (#143).
|
||||||
|
ApplyDeferred.ambiguous_with_all(),
|
||||||
// After each animation finishes, pop the next chain segment.
|
// After each animation finishes, pop the next chain segment.
|
||||||
advance_animation_chains,
|
advance_animation_chains,
|
||||||
// Interaction visuals (run after animation for final positions).
|
// Interaction visuals (run after animation for final positions).
|
||||||
@@ -123,10 +138,24 @@ impl Plugin for CardAnimationPlugin {
|
|||||||
apply_hover_scale,
|
apply_hover_scale,
|
||||||
apply_drag_visual,
|
apply_drag_visual,
|
||||||
// Drain buffered inputs only when no animations remain.
|
// Drain buffered inputs only when no animations remain.
|
||||||
drain_input_buffer,
|
// It re-emits buffered undo/new-game requests, so it
|
||||||
|
// joins the self-ambiguous writer clubs (#143).
|
||||||
|
drain_input_buffer
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.in_set(CardTweenFx),
|
||||||
|
)
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
CardTweenFx
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::table_plugin::MarkerVisuals)
|
||||||
|
// HUD text pulses touch Transform on UI entities only —
|
||||||
|
// disjoint from the card entities this set animates
|
||||||
|
// (same rationale as UiTextFx × BoardVisuals, #143).
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -592,7 +592,9 @@ impl Plugin for CardPlugin {
|
|||||||
tick_right_click_highlights,
|
tick_right_click_highlights,
|
||||||
clear_right_click_highlights_on_state_change,
|
clear_right_click_highlights_on_state_change,
|
||||||
clear_right_click_highlights_on_pause,
|
clear_right_click_highlights_on_pause,
|
||||||
tick_hint_highlight,
|
// Reads only the anim-speed/reduce-motion flags; a frame
|
||||||
|
// of lag is invisible (#143).
|
||||||
|
tick_hint_highlight.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
update_stock_empty_indicator,
|
update_stock_empty_indicator,
|
||||||
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
|
update_stock_count_badge.run_if(resource_changed::<GameStateResource>),
|
||||||
collect_resize_events,
|
collect_resize_events,
|
||||||
|
|||||||
@@ -11,8 +11,7 @@ use solitaire_data::{challenge_count, challenge_seed_for, save_progress_to};
|
|||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartChallengeRequestEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
/// Minimum player level required to start a Challenge run.
|
/// Minimum player level required to start a Challenge run.
|
||||||
@@ -38,8 +37,28 @@ impl Plugin for ChallengePlugin {
|
|||||||
.add_message::<StartChallengeRequestEvent>()
|
.add_message::<StartChallengeRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
|
// Run after ProgressUpdate so we don't fight ProgressPlugin's add_xp.
|
||||||
.add_systems(Update, advance_on_challenge_win.after(ProgressUpdate))
|
// Ordering comes from the mode-completion chain configured in
|
||||||
.add_systems(Update, handle_start_challenge_request.before(GameMutation));
|
// progress_plugin; the toast it emits joins the writer club
|
||||||
|
// drained by the toast pipeline this frame (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
advance_on_challenge_win
|
||||||
|
.in_set(crate::progress_plugin::ChallengeCompletion)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// ModeStart places it between the stats abandon-recorder and
|
||||||
|
// GameMutation; it shares the new-game request/toast streams
|
||||||
|
// with the other writers (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
handle_start_challenge_request
|
||||||
|
.in_set(crate::game_plugin::ModeStart)
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,12 +76,31 @@ pub struct CursorPlugin;
|
|||||||
|
|
||||||
impl Plugin for CursorPlugin {
|
impl Plugin for CursorPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
|
// All three read the DragState/game state the input chain and
|
||||||
|
// mutation systems settle, so they run after both. The highlight
|
||||||
|
// painters write card/overlay Sprites and join the visual spine
|
||||||
|
// ahead of the board painters; the cursor icon touches only the
|
||||||
|
// window cursor (#143).
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_cursor_icon,
|
update_cursor_icon
|
||||||
|
.after(crate::input_plugin::PointerInput)
|
||||||
|
.after(crate::game_plugin::GameMutation)
|
||||||
|
.after(crate::selection_plugin::SelectionFx)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
|
(
|
||||||
update_drop_highlights.run_if(resource_changed::<DragState>),
|
update_drop_highlights.run_if(resource_changed::<DragState>),
|
||||||
update_drop_target_overlays,
|
update_drop_target_overlays,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(crate::input_plugin::PointerInput)
|
||||||
|
.after(crate::game_plugin::GameMutation)
|
||||||
|
// Selection maintenance clears DragState and the hint
|
||||||
|
// poll writes highlight sprites — both settle first.
|
||||||
|
.after(crate::selection_plugin::SelectionFx)
|
||||||
|
.after(crate::pending_hint::PendingHint)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ use crate::events::{
|
|||||||
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
|
GameWonEvent, InfoToastEvent, NewGameRequestEvent, StartDailyChallengeRequestEvent,
|
||||||
WarningToastEvent, XpAwardedEvent,
|
WarningToastEvent, XpAwardedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
@@ -124,17 +124,51 @@ impl Plugin for DailyChallengePlugin {
|
|||||||
.add_message::<StartDailyChallengeRequestEvent>()
|
.add_message::<StartDailyChallengeRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>()
|
.add_message::<WarningToastEvent>()
|
||||||
.add_message::<XpAwardedEvent>()
|
.add_message::<XpAwardedEvent>()
|
||||||
// record/award after the base ProgressUpdate so we don't fight
|
// Ordering comes from the mode-completion chain configured in
|
||||||
// ProgressPlugin's add_xp on the same frame.
|
// progress_plugin; the toast it emits joins the writer club
|
||||||
.add_systems(Update, handle_daily_completion.after(ProgressUpdate))
|
// drained by the toast pipeline this frame (#143).
|
||||||
.add_systems(Update, handle_start_daily_request.before(GameMutation))
|
.add_systems(
|
||||||
.add_systems(Update, check_daily_expiry_warning)
|
Update,
|
||||||
.add_systems(Update, check_date_rollover);
|
handle_daily_completion
|
||||||
|
.in_set(crate::progress_plugin::DailyCompletion)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// ModeStart places it between the stats abandon-recorder and
|
||||||
|
// GameMutation; it shares the new-game request/toast streams
|
||||||
|
// with the other writers (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
handle_start_daily_request
|
||||||
|
.in_set(crate::game_plugin::ModeStart)
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// One chain for the DailyChallengeResource writers/readers:
|
||||||
|
// the local date rollover replaces today's challenge and the
|
||||||
|
// expiry warning reads it. Before ModeStart so a rollover
|
||||||
|
// lands before the start handler reads the seed this frame
|
||||||
|
// (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
check_date_rollover,
|
||||||
|
check_daily_expiry_warning
|
||||||
|
.in_set(crate::game_plugin::WarningToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::WarningToastWriters),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.before(crate::game_plugin::ModeStart),
|
||||||
|
);
|
||||||
|
|
||||||
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
|
// Server-challenge fetch uses SyncProviderResource (reqwest), not available on wasm.
|
||||||
|
// The poll replaces DailyChallengeResource, so it heads the same
|
||||||
|
// pre-ModeStart timeline as the rollover/expiry chain above (#143).
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
app.add_systems(Startup, fetch_server_challenge)
|
app.add_systems(Startup, fetch_server_challenge)
|
||||||
.add_systems(Update, poll_server_challenge);
|
.add_systems(Update, poll_server_challenge.before(check_date_rollover));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ use solitaire_core::game_state::{DifficultyLevel, GameMode};
|
|||||||
use solitaire_data::difficulty_seeds::seeds_for;
|
use solitaire_data::difficulty_seeds::seeds_for;
|
||||||
|
|
||||||
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
|
use crate::events::{NewGameRequestEvent, StartDifficultyRequestEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Resources
|
// Resources
|
||||||
@@ -77,7 +76,16 @@ impl Plugin for DifficultyPlugin {
|
|||||||
app.init_resource::<DifficultyIndexResource>()
|
app.init_resource::<DifficultyIndexResource>()
|
||||||
.add_message::<StartDifficultyRequestEvent>()
|
.add_message::<StartDifficultyRequestEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_systems(Update, handle_difficulty_request.before(GameMutation));
|
// ModeStart places it between the stats abandon-recorder and
|
||||||
|
// GameMutation; it shares the new-game request stream with the
|
||||||
|
// other writers (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
handle_difficulty_request
|
||||||
|
.in_set(crate::game_plugin::ModeStart)
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,18 @@ pub struct SyncLogoutRequestEvent;
|
|||||||
#[derive(Message, Debug, Clone, Copy, Default)]
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
pub struct DeleteAccountRequestEvent;
|
pub struct DeleteAccountRequestEvent;
|
||||||
|
|
||||||
|
/// Request to export every JSON save into one local backup bundle
|
||||||
|
/// (Phase M — sync transparency). Fired by the Account tab's "Export
|
||||||
|
/// data" button; handled in the `Last` schedule by `settings_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct DataExportRequestEvent;
|
||||||
|
|
||||||
|
/// Request to restore every JSON save from the local backup bundle
|
||||||
|
/// (Phase M — sync transparency). Fired by the Account tab's "Import
|
||||||
|
/// data" button; handled in the `Last` schedule by `settings_plugin`.
|
||||||
|
#[derive(Message, Debug, Clone, Copy, Default)]
|
||||||
|
pub struct DataImportRequestEvent;
|
||||||
|
|
||||||
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
|
/// Request to toggle the pause overlay. Fired by the HUD "Pause" button so
|
||||||
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
/// the same toggle path runs whether the player presses `Esc` or clicks.
|
||||||
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
/// Consumed by `pause_plugin::toggle_pause`, which honours the same drag /
|
||||||
|
|||||||
@@ -198,6 +198,22 @@ pub fn deal_stagger_jitter(card_id: u32) -> f32 {
|
|||||||
/// animation systems.
|
/// animation systems.
|
||||||
pub struct FeedbackAnimPlugin;
|
pub struct FeedbackAnimPlugin;
|
||||||
|
|
||||||
|
/// Set wrapping the shake/settle/deal/flourish systems. Last anim stage on
|
||||||
|
/// the visual spine: …MarkerVisuals < CardTweenFx <
|
||||||
|
/// [`crate::animation_plugin::AnimFx`] < FeedbackFx. Runs after
|
||||||
|
/// MarkerVisuals because the foundation flourish tints pile-marker sprites,
|
||||||
|
/// which the marker painters also write; the tint must win within the frame
|
||||||
|
/// (#143). GameMutation ordering is inherited transitively.
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct FeedbackFx;
|
||||||
|
|
||||||
|
/// Set wrapping the hint-ghost chain (spawn → tick → despawn). Ghost
|
||||||
|
/// entities are `Without<CardEntity>` sprites owned solely by this chain —
|
||||||
|
/// systems that animate other entity domains (e.g. the win-summary camera
|
||||||
|
/// shake) declare themselves `.ambiguous_with(HintGhostFx)` (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct HintGhostFx;
|
||||||
|
|
||||||
impl Plugin for FeedbackAnimPlugin {
|
impl Plugin for FeedbackAnimPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// Register the events this plugin consumes so it can run in isolation
|
// Register the events this plugin consumes so it can run in isolation
|
||||||
@@ -211,22 +227,36 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<HintVisualEvent>()
|
.add_message::<HintVisualEvent>()
|
||||||
.add_message::<StateChangedEvent>()
|
.add_message::<StateChangedEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
.add_message::<RequestRedraw>()
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
FeedbackFx
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::animation_plugin::AnimFx)
|
||||||
|
// HUD text pulses are disjoint from the card/marker
|
||||||
|
// entities this set animates (see UiTextFx ×
|
||||||
|
// BoardVisuals, #143).
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
start_shake_anim.after(GameMutation),
|
// Reads only the reduce-motion flag; a frame of lag is
|
||||||
|
// invisible (#143).
|
||||||
|
start_shake_anim.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
tick_shake_anim,
|
tick_shake_anim,
|
||||||
start_settle_anim.after(GameMutation),
|
start_settle_anim,
|
||||||
// tick_foundation_flourish writes the full Transform.scale
|
// tick_foundation_flourish writes the full Transform.scale
|
||||||
// (Vec3); tick_settle_anim writes only scale.y on top of
|
// (Vec3); tick_settle_anim writes only scale.y on top of
|
||||||
// it. Ordering ensures the settle's y-only write always
|
// it. Ordering ensures the settle's y-only write always
|
||||||
// applies last so it wins on the ~0.15 s overlap when both
|
// applies last so it wins on the ~0.15 s overlap when both
|
||||||
// components are present on the same King entity.
|
// components are present on the same King entity.
|
||||||
tick_foundation_flourish.before(tick_settle_anim),
|
tick_foundation_flourish,
|
||||||
tick_settle_anim,
|
tick_settle_anim,
|
||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim,
|
||||||
start_foundation_flourish.after(GameMutation),
|
start_foundation_flourish,
|
||||||
),
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(FeedbackFx),
|
||||||
)
|
)
|
||||||
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
|
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
|
||||||
// so it orders after the board painters; the tick only touches
|
// so it orders after the board painters; the tick only touches
|
||||||
@@ -240,7 +270,24 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
tick_hint_ghosts,
|
tick_hint_ghosts,
|
||||||
despawn_hint_ghosts_on_state_change.after(GameMutation),
|
despawn_hint_ghosts_on_state_change.after(GameMutation),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.in_set(HintGhostFx)
|
||||||
|
// The spawn reads card Sprites the marker painters (theme
|
||||||
|
// apply, pile highlights) also write — those settle first.
|
||||||
|
// Ghost entities are Without<CardEntity> and never HUD
|
||||||
|
// chrome, and the spawn reads only the reduce-motion flag
|
||||||
|
// (frame-lag invisible) (#143).
|
||||||
|
.after(crate::table_plugin::MarkerVisuals)
|
||||||
|
// …and the card tweens/slides, whose Transform writes
|
||||||
|
// the spawn snapshot must not race.
|
||||||
|
.after(crate::card_animation::CardTweenFx)
|
||||||
|
// FeedbackFx (deal slide, shake) is the last Transform
|
||||||
|
// writer on the spine — the ghost snapshot reads after
|
||||||
|
// the whole visual pipeline has settled.
|
||||||
|
.after(FeedbackFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,6 +99,38 @@ pub struct DrawRequestWriters;
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct InfoToastWriters;
|
pub struct InfoToastWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous club for writers of `MoveRequestEvent` (drag drops, stock
|
||||||
|
/// clicks, radial menu, selection keys, replay step button). Consumers drain
|
||||||
|
/// the whole queue — append order is meaningless (#143). Same rules as
|
||||||
|
/// [`NewGameRequestWriters`]: do NOT hang ordering edges off this set.
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct MoveRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous club for writers of `WarningToastEvent` (daily-expiry
|
||||||
|
/// warning, leaderboard opt-in/out failure polls). The toast pipeline
|
||||||
|
/// drains the whole queue — append order is meaningless (#143). Same
|
||||||
|
/// rules as [`InfoToastWriters`].
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct WarningToastWriters;
|
||||||
|
|
||||||
|
/// Set + self-ambiguous club for the mode-start request handlers (challenge,
|
||||||
|
/// daily, time-attack, play-by-seed, difficulty). Each drains its own
|
||||||
|
/// request stream and resets `GameStateResource` for its mode; only one
|
||||||
|
/// stream carries an event on any real frame, so member order is irrelevant
|
||||||
|
/// (club), while the set as a whole runs between the stats abandon-recorder
|
||||||
|
/// ([`crate::stats_plugin::AbandonRecord`], which must read the old game
|
||||||
|
/// first) and [`GameMutation`] (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ModeStart;
|
||||||
|
|
||||||
|
/// Set wrapping `GamePlugin`'s pre-mutation game-state spine (elapsed-time
|
||||||
|
/// tick, settings sync, restore prompt). Sits between
|
||||||
|
/// [`crate::settings_plugin::SettingsMutation`] and [`GameMutation`]; other
|
||||||
|
/// pre-mutation readers of `GameStateResource` (e.g. the stats
|
||||||
|
/// abandon-recorder) order themselves `.after(PreMutation)` (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PreMutation;
|
||||||
|
|
||||||
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
/// Persistence path for the in-progress game state file. `None` disables I/O.
|
||||||
#[derive(Resource, Debug, Clone)]
|
#[derive(Resource, Debug, Clone)]
|
||||||
pub struct GameStatePath(pub Option<PathBuf>);
|
pub struct GameStatePath(pub Option<PathBuf>);
|
||||||
@@ -267,7 +299,14 @@ impl Plugin for GamePlugin {
|
|||||||
.in_set(InfoToastWriters)
|
.in_set(InfoToastWriters)
|
||||||
.ambiguous_with(InfoToastWriters),
|
.ambiguous_with(InfoToastWriters),
|
||||||
)
|
)
|
||||||
.add_systems(Update, record_replay_on_win.after(GameMutation))
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
record_replay_on_win
|
||||||
|
.after(GameMutation)
|
||||||
|
// Before the playback skip-recorder (which runs after
|
||||||
|
// AutoComplete) — both touch RecordingReplay (#143).
|
||||||
|
.before(crate::auto_complete_plugin::AutoComplete),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -301,9 +340,20 @@ impl Plugin for GamePlugin {
|
|||||||
.ambiguous_with(NewGameRequestWriters),
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
|
.in_set(PreMutation),
|
||||||
|
)
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
PreMutation
|
||||||
.after(crate::settings_plugin::SettingsMutation)
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
.before(GameMutation),
|
.before(GameMutation),
|
||||||
)
|
)
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
ModeStart
|
||||||
|
.after(crate::stats_plugin::AbandonRecord)
|
||||||
|
.before(GameMutation),
|
||||||
|
)
|
||||||
.init_resource::<AutoSaveTimer>()
|
.init_resource::<AutoSaveTimer>()
|
||||||
.add_systems(Update, auto_save_game_state.after(GameMutation))
|
.add_systems(Update, auto_save_game_state.after(GameMutation))
|
||||||
.add_systems(Last, save_game_state_on_exit);
|
.add_systems(Last, save_game_state_on_exit);
|
||||||
|
|||||||
@@ -54,12 +54,23 @@ impl Plugin for HelpPlugin {
|
|||||||
.add_message::<TouchInput>()
|
.add_message::<TouchInput>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(toggle_help_screen, handle_help_close_button)
|
||||||
toggle_help_screen,
|
.chain()
|
||||||
handle_help_close_button,
|
// FocusKeys consumes Esc after the modal handlers
|
||||||
scroll_help_panel,
|
// (mirrors toggle_settings_screen, #143).
|
||||||
crate::ui_modal::touch_scroll_panel::<HelpScrollable>,
|
.before(crate::ui_focus::FocusKeys),
|
||||||
),
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
scroll_help_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
crate::ui_modal::touch_scroll_panel::<HelpScrollable>
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -334,19 +334,45 @@ impl Plugin for HomePlugin {
|
|||||||
spawn_home_on_launch,
|
spawn_home_on_launch,
|
||||||
toggle_home_screen,
|
toggle_home_screen,
|
||||||
attach_focusable_to_home_mode_cards,
|
attach_focusable_to_home_mode_cards,
|
||||||
handle_home_card_click,
|
handle_home_card_click
|
||||||
handle_home_new_game_hero,
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
handle_home_new_game_hero
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
handle_home_cancel_button,
|
handle_home_cancel_button,
|
||||||
handle_home_profile_chip,
|
handle_home_profile_chip
|
||||||
|
.in_set(crate::profile_plugin::ProfileToggleWriters)
|
||||||
|
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
|
||||||
handle_home_draw_mode_buttons,
|
handle_home_draw_mode_buttons,
|
||||||
handle_home_winnable_toggle,
|
handle_home_winnable_toggle,
|
||||||
handle_home_deal_options_toggle,
|
handle_home_deal_options_toggle,
|
||||||
handle_home_difficulty_chip_click,
|
handle_home_difficulty_chip_click
|
||||||
handle_home_digit_keys,
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
|
handle_home_digit_keys
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.before(crate::ui_focus::FocusKeys),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
// The home context repaints from this frame's settled
|
||||||
|
// progress/stats/achievements; settings access joins the
|
||||||
|
// screen-plugin club (#143).
|
||||||
|
.after(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.after(crate::stats_plugin::StatsUpdate)
|
||||||
|
.after(crate::achievement_plugin::AchievementEval)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.add_systems(Update, scroll_home_panel);
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
scroll_home_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,12 +109,19 @@ pub(super) fn apply_action_fade(
|
|||||||
/// Visual feedback for every action button — paints idle / hover / pressed
|
/// Visual feedback for every action button — paints idle / hover / pressed
|
||||||
/// states by mutating `BackgroundColor` whenever the interaction state
|
/// states by mutating `BackgroundColor` whenever the interaction state
|
||||||
/// changes. One query covers all action buttons via the shared
|
/// changes. One query covers all action buttons via the shared
|
||||||
/// `ActionButton` marker.
|
/// `ActionButton` marker. Glass tab-bar buttons are excluded: they carry
|
||||||
|
/// their own per-button colour triple and are painted by
|
||||||
|
/// `paint_glass_tab_buttons` instead (this system's opaque `ACTION_BTN_*`
|
||||||
|
/// fills would destroy the translucent glass look).
|
||||||
#[allow(clippy::type_complexity)]
|
#[allow(clippy::type_complexity)]
|
||||||
pub(super) fn paint_action_buttons(
|
pub(super) fn paint_action_buttons(
|
||||||
mut buttons: Query<
|
mut buttons: Query<
|
||||||
(&Interaction, &mut BackgroundColor),
|
(&Interaction, &mut BackgroundColor),
|
||||||
(With<ActionButton>, Changed<Interaction>),
|
(
|
||||||
|
With<ActionButton>,
|
||||||
|
Without<GlassTabButton>,
|
||||||
|
Changed<Interaction>,
|
||||||
|
),
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
for (interaction, mut bg) in &mut buttons {
|
for (interaction, mut bg) in &mut buttons {
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ use bevy::window::WindowResized;
|
|||||||
mod fx;
|
mod fx;
|
||||||
mod interaction;
|
mod interaction;
|
||||||
mod spawn;
|
mod spawn;
|
||||||
|
pub(crate) mod tab_bar;
|
||||||
mod updates;
|
mod updates;
|
||||||
|
|
||||||
pub use fx::*;
|
pub use fx::*;
|
||||||
use interaction::*;
|
use interaction::*;
|
||||||
use spawn::*;
|
use spawn::*;
|
||||||
|
use tab_bar::*;
|
||||||
use updates::*;
|
use updates::*;
|
||||||
|
|
||||||
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
// On wasm32 AvatarPlugin is gated out; define a placeholder type so the
|
||||||
@@ -515,13 +517,31 @@ impl Plugin for HudPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(restore_hud_on_modal, apply_hud_visibility)
|
(restore_hud_on_modal, apply_hud_visibility)
|
||||||
.chain()
|
.chain()
|
||||||
.before(LayoutSystem::UpdateOnResize),
|
.before(LayoutSystem::UpdateOnResize)
|
||||||
|
// Toggles Visibility on HUD chrome roots only — joins
|
||||||
|
// the chrome club (#143).
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
update_hud_avatar.after(crate::settings_plugin::SettingsMutation),
|
update_hud_avatar
|
||||||
handle_avatar_button.ambiguous_with(HudButtons),
|
// Same-frame-exact after the settings spine, so NOT
|
||||||
|
// a SettingsAccess member — but the club's readers
|
||||||
|
// and the home deal-option writers touch settings
|
||||||
|
// fields the avatar never reads (#143).
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess)
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
// Two more settings writers arrived with the wider
|
||||||
|
// ambiguity cluster: the pause overlay's draw-mode
|
||||||
|
// toggle and the achievement onboarding flag (#143).
|
||||||
|
.after(crate::pause_plugin::PauseMutation)
|
||||||
|
.after(crate::game_plugin::InfoToastWriters),
|
||||||
|
handle_avatar_button
|
||||||
|
.ambiguous_with(HudButtons)
|
||||||
|
.in_set(crate::profile_plugin::ProfileToggleWriters)
|
||||||
|
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
@@ -536,7 +556,11 @@ impl Plugin for HudPlugin {
|
|||||||
// the board painters that resize card/label text (#143).
|
// the board painters that resize card/label text (#143).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
update_hud_typography.after(crate::card_plugin::BoardVisuals),
|
update_hud_typography
|
||||||
|
.after(crate::card_plugin::BoardVisuals)
|
||||||
|
// Writes HUD TextFont only — chrome club (#143).
|
||||||
|
.in_set(UiTextFx)
|
||||||
|
.ambiguous_with(UiTextFx),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -549,16 +573,26 @@ impl Plugin for HudPlugin {
|
|||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
.in_set(UiTextFx)
|
.in_set(UiTextFx)
|
||||||
.ambiguous_with(UiTextFx)
|
.ambiguous_with(UiTextFx)
|
||||||
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
// Reads only the reduce-motion flag; a frame of lag is
|
||||||
|
// invisible (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(start_streak_flourish, advance_streak_flourish)
|
(start_streak_flourish, advance_streak_flourish)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
|
// Reads the streak-milestone events StatsUpdate emits.
|
||||||
|
.after(crate::stats_plugin::StatsUpdate)
|
||||||
.in_set(UiTextFx)
|
.in_set(UiTextFx)
|
||||||
.ambiguous_with(UiTextFx)
|
.ambiguous_with(UiTextFx)
|
||||||
.ambiguous_with(crate::card_plugin::BoardVisuals),
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
// Reads only the reduce-motion flag; a frame of lag is
|
||||||
|
// invisible (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -582,19 +616,48 @@ impl Plugin for HudPlugin {
|
|||||||
handle_help_button,
|
handle_help_button,
|
||||||
handle_hint_button
|
handle_hint_button
|
||||||
.after(GameMutation)
|
.after(GameMutation)
|
||||||
|
// A press cancels/spawns the solver task before
|
||||||
|
// this frame's drop/poll pass (#143).
|
||||||
|
.before(crate::pending_hint::PendingHint)
|
||||||
.in_set(crate::game_plugin::InfoToastWriters)
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
handle_modes_button,
|
// The modes/menu popovers repaint the player level from
|
||||||
|
// ProgressResource — read it after this frame's XP award
|
||||||
|
// settles (#143).
|
||||||
|
handle_modes_button
|
||||||
|
.after(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.after(crate::achievement_plugin::AchievementEval),
|
||||||
handle_mode_option_click
|
handle_mode_option_click
|
||||||
.in_set(NewGameRequestWriters)
|
.in_set(NewGameRequestWriters)
|
||||||
.ambiguous_with(NewGameRequestWriters),
|
.ambiguous_with(NewGameRequestWriters),
|
||||||
handle_modes_backdrop_click,
|
handle_modes_backdrop_click,
|
||||||
close_modes_popover_on_escape,
|
close_modes_popover_on_escape,
|
||||||
handle_menu_button,
|
handle_menu_button,
|
||||||
handle_menu_option_click,
|
handle_menu_option_click
|
||||||
|
.after(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.after(crate::achievement_plugin::AchievementEval)
|
||||||
|
.in_set(crate::profile_plugin::ProfileToggleWriters)
|
||||||
|
.ambiguous_with(crate::profile_plugin::ProfileToggleWriters),
|
||||||
handle_menu_backdrop_click,
|
handle_menu_backdrop_click,
|
||||||
close_menu_popover_on_escape,
|
close_menu_popover_on_escape,
|
||||||
paint_action_buttons,
|
paint_action_buttons,
|
||||||
|
paint_glass_tab_buttons,
|
||||||
|
drive_tab_expansion,
|
||||||
|
// Writes tab-label TextColor and reads TextFont — entity-
|
||||||
|
// disjoint from the UiTextFx readout writers (tab labels
|
||||||
|
// vs HUD readouts), which Bevy can't prove, so declare it.
|
||||||
|
animate_tab_expansion
|
||||||
|
.ambiguous_with(UiTextFx)
|
||||||
|
// Tab-label fonts are never card/corner-label fonts.
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
// Reads only reduce-motion; a frame of lag is
|
||||||
|
// invisible, so it joins the settings-reader club.
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
// Reads only reduce-motion — settings-reader club (#143).
|
||||||
|
duck_tab_bar_during_drag
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(HudButtons)
|
.in_set(HudButtons)
|
||||||
|
|||||||
@@ -360,6 +360,7 @@ pub(super) fn handle_avatar_button(
|
|||||||
pub(super) fn spawn_action_buttons(
|
pub(super) fn spawn_action_buttons(
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
images: Option<ResMut<Assets<Image>>>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
) {
|
) {
|
||||||
let action_font_size =
|
let action_font_size =
|
||||||
@@ -370,9 +371,18 @@ pub(super) fn spawn_action_buttons(
|
|||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
|
|
||||||
// Bottom bar: full-width, centered, sits above the gesture-navigation zone.
|
// Touch layout: the floating glass tab bar (pill + circular Menu).
|
||||||
|
// `Assets<Image>` is absent under `MinimalPlugins`; `rasterize_into`
|
||||||
|
// returning `None` switches the bar to its text-fallback form.
|
||||||
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
|
let icons = images.and_then(|mut imgs| TabIcons::rasterize_into(&mut imgs));
|
||||||
|
spawn_glass_tab_bar(&mut commands, &font, icons.as_ref());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Desktop bottom bar: full-width, centered, docked to the bottom edge.
|
||||||
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
// `SafeAreaAnchoredBottom` applies the correct logical-pixel inset once
|
||||||
// Android reports it (frames 1-3); initial value is 0.0.
|
// the platform reports it; initial value is 0.0.
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
Node {
|
Node {
|
||||||
@@ -400,73 +410,10 @@ pub(super) fn spawn_action_buttons(
|
|||||||
HudActionBar,
|
HudActionBar,
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
if USE_TOUCH_UI_LAYOUT {
|
|
||||||
spawn_touch_action_bar(row, &font);
|
|
||||||
} else {
|
|
||||||
spawn_desktop_action_bar(row, &font);
|
spawn_desktop_action_bar(row, &font);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase F touch action bar — five buttons, three of them big.
|
|
||||||
///
|
|
||||||
/// The core gameplay trio (**Undo · Draw · Hint**) gets enlarged
|
|
||||||
/// thumb-reach targets; Menu and Pause stay compact at the edges. The
|
|
||||||
/// utility actions the desktop bar carries are reachable elsewhere on
|
|
||||||
/// touch and are deliberately absent here: Help lives in Menu → System,
|
|
||||||
/// mode switching lives on the Home screen (Phase B), and New Game is
|
|
||||||
/// Home's hero button. Draw duplicates the stock-pile tap so the most
|
|
||||||
/// frequent action of all no longer requires reaching the top half of a
|
|
||||||
/// tall folded screen.
|
|
||||||
pub(super) fn spawn_touch_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
|
||||||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
|
||||||
// so Tab (external keyboard) cycles the bar in visual reading order.
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
MenuButton,
|
|
||||||
"Menu",
|
|
||||||
None,
|
|
||||||
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
|
|
||||||
font,
|
|
||||||
0,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_primary_action_button(
|
|
||||||
row,
|
|
||||||
UndoButton,
|
|
||||||
"Undo",
|
|
||||||
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
|
||||||
font,
|
|
||||||
1,
|
|
||||||
);
|
|
||||||
spawn_primary_action_button(
|
|
||||||
row,
|
|
||||||
DrawButton,
|
|
||||||
"Draw",
|
|
||||||
"Draw from the stock — same as tapping the deck.",
|
|
||||||
font,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
spawn_primary_action_button(
|
|
||||||
row,
|
|
||||||
HintButton,
|
|
||||||
TOUCH_HINT_LABEL,
|
|
||||||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
|
||||||
font,
|
|
||||||
3,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
PauseButton,
|
|
||||||
"Pause",
|
|
||||||
None,
|
|
||||||
"Pause the game and freeze the timer.",
|
|
||||||
font,
|
|
||||||
4,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
|
/// Desktop action bar — unchanged by Phase F (decision 5: the touch bar
|
||||||
/// is touch-only). All seven actions, uniform sizing.
|
/// is touch-only). All seven actions, uniform sizing.
|
||||||
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||||||
@@ -585,44 +532,9 @@ pub(super) fn spawn_action_button<M: Component>(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enlarged variant for the touch bar's core gameplay trio (Phase F):
|
/// Shared body of [`spawn_action_button`] — one place owns the component
|
||||||
/// bigger padding / minimum target and a scaled-up label so Undo, Draw,
|
/// set so `paint_action_buttons`, tooltips, and the focus ring treat every
|
||||||
/// and Hint read (and hit) as the primary actions. Hotkey chips are
|
/// bar button identically.
|
||||||
/// irrelevant on touch, so the variant takes none.
|
|
||||||
pub(super) fn spawn_primary_action_button<M: Component>(
|
|
||||||
row: &mut ChildSpawnerCommands,
|
|
||||||
marker: M,
|
|
||||||
label: &str,
|
|
||||||
tooltip: &'static str,
|
|
||||||
font: &TextFont,
|
|
||||||
order: i32,
|
|
||||||
) {
|
|
||||||
let (pad, min_w, min_h) = primary_action_button_metrics();
|
|
||||||
let primary_font = TextFont {
|
|
||||||
font: font.font.clone(),
|
|
||||||
// 1.35× the bar's responsive size, capped so landscape tablets
|
|
||||||
// don't blow the row height out.
|
|
||||||
font_size: (font.font_size * 1.35).min(24.0),
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
spawn_action_button_sized(
|
|
||||||
row,
|
|
||||||
marker,
|
|
||||||
label,
|
|
||||||
None,
|
|
||||||
tooltip,
|
|
||||||
&primary_font,
|
|
||||||
order,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
pad,
|
|
||||||
min_w,
|
|
||||||
min_h,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared body of [`spawn_action_button`] / [`spawn_primary_action_button`]
|
|
||||||
/// — one place owns the component set so `paint_action_buttons`, tooltips,
|
|
||||||
/// and the focus ring treat every bar button identically.
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn spawn_action_button_sized<M: Component>(
|
fn spawn_action_button_sized<M: Component>(
|
||||||
row: &mut ChildSpawnerCommands,
|
row: &mut ChildSpawnerCommands,
|
||||||
|
|||||||
@@ -0,0 +1,529 @@
|
|||||||
|
//! Floating "liquid glass" touch tab bar — layout and animation.
|
||||||
|
//!
|
||||||
|
//! Replaces the docked full-width touch action bar (Phase F) with an
|
||||||
|
//! iOS-26-style floating arrangement: a pill of icon buttons hovering
|
||||||
|
//! above the bottom edge, plus a detached circular Menu button to its
|
||||||
|
//! right. The *visual treatment* (translucent sheen, specular rim, drop
|
||||||
|
//! shadow) lives in [`crate::ui_glass`]; this module owns only geometry,
|
||||||
|
//! button wiring, and the expand/collapse label animation, so the glass
|
||||||
|
//! material can be swapped without touching layout.
|
||||||
|
//!
|
||||||
|
//! Interaction compatibility: every button keeps the same marker
|
||||||
|
//! components as the old bar (`UndoButton`, `DrawButton`, …) plus
|
||||||
|
//! `ActionButton`, `Button`, `Tooltip`, and `Focusable`, so the click
|
||||||
|
//! handlers, tooltips, focus ring, and the tap-to-toggle chrome guard in
|
||||||
|
//! `interaction.rs` all work unchanged.
|
||||||
|
//!
|
||||||
|
//! Expansion model (user decision, 2026-07-15): **Draw** — the most
|
||||||
|
//! frequent action — is the persistently-expanded "active" pill showing
|
||||||
|
//! icon + label; the other buttons are icon-only and reveal their label
|
||||||
|
//! with a width-animated slide while pressed.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::assets::hud_icon_svg::HudIcon;
|
||||||
|
use crate::ui_glass::{GLASS_BORDER_PX, glass_surface};
|
||||||
|
use crate::ui_theme::{ACCENT_PRIMARY_HOVER, SPACE_3};
|
||||||
|
use bevy::text::{LineBreak, TextLayout};
|
||||||
|
use bevy::ui::{Overflow, UiTransform, Val2};
|
||||||
|
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use crate::safe_area::SafeAreaInsets;
|
||||||
|
|
||||||
|
/// Height of both the pill and the circular Menu button, in logical px.
|
||||||
|
/// 64 keeps the inner 48-px buttons comfortably past the 44-px touch
|
||||||
|
/// floor with 8 px of glass around them.
|
||||||
|
pub(crate) const TAB_BAR_HEIGHT: f32 = 64.0;
|
||||||
|
|
||||||
|
/// Gap between the bar and the bottom screen edge (the "floating" margin;
|
||||||
|
/// the Android gesture-bar inset is added on top by `SafeAreaAnchoredBottom`).
|
||||||
|
const TAB_BAR_MARGIN: f32 = SPACE_3;
|
||||||
|
|
||||||
|
/// Vertical space UI stacked above the bar (the toast stack) must clear:
|
||||||
|
/// floating margin + bar height + one spacing step of breathing room.
|
||||||
|
pub(crate) const TAB_BAR_CLEARANCE_PX: f32 = TAB_BAR_MARGIN + TAB_BAR_HEIGHT + SPACE_2;
|
||||||
|
|
||||||
|
/// Diameter of a button inside the pill (and its fully-round radius ×2).
|
||||||
|
const TAB_BUTTON_SIZE: f32 = 48.0;
|
||||||
|
|
||||||
|
/// Displayed icon size in logical px (texture is rasterised larger; see
|
||||||
|
/// `assets::hud_icon_svg::HUD_ICON_TEXTURE_PX`).
|
||||||
|
const TAB_ICON_PX: f32 = 24.0;
|
||||||
|
|
||||||
|
/// FiraMono glyph advance as a fraction of font size. The bar is
|
||||||
|
/// monospace-only, so `chars × advance × font_size` is the exact label
|
||||||
|
/// width — the same ratio `action_bar_font_size` documents.
|
||||||
|
const FIRA_ADVANCE: f32 = 0.62;
|
||||||
|
|
||||||
|
/// Gap between an icon and its revealed label, folded into the animated
|
||||||
|
/// wrapper width so it collapses together with the text.
|
||||||
|
const LABEL_LEAD_PX: f32 = SPACE_2;
|
||||||
|
|
||||||
|
/// Idle fill of an inactive icon button — fully transparent so the pill's
|
||||||
|
/// glass shows through untouched.
|
||||||
|
const GLASS_BTN_IDLE: Color = Color::srgba(1.0, 1.0, 1.0, 0.0);
|
||||||
|
/// Hover fill of an inactive icon button (desktop pointer / DPAD focus).
|
||||||
|
const GLASS_BTN_HOVER: Color = Color::srgba(1.0, 1.0, 1.0, 0.08);
|
||||||
|
/// Pressed fill of an inactive icon button.
|
||||||
|
const GLASS_BTN_PRESSED: Color = Color::srgba(1.0, 1.0, 1.0, 0.14);
|
||||||
|
/// Pressed state of the active (accent-filled) pill — darker than idle so
|
||||||
|
/// the press reads as the surface receding, mirroring `BG_ELEVATED_PRESSED`.
|
||||||
|
const GLASS_ACTIVE_PRESSED: Color = Color::srgb(0.55, 0.22, 0.22);
|
||||||
|
|
||||||
|
/// Idle / hovered / pressed fills for one tab-bar button. Presence of this
|
||||||
|
/// component also routes the button to `paint_glass_tab_buttons` instead of
|
||||||
|
/// the legacy `paint_action_buttons` (which excludes it).
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct GlassTabButton {
|
||||||
|
idle: Color,
|
||||||
|
hovered: Color,
|
||||||
|
pressed: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Animated label-reveal state, on the clipping wrapper node between the
|
||||||
|
/// button and its `Text` label. `progress` ∈ [0, 1] maps to wrapper width
|
||||||
|
/// via [`expansion_width`]; `target` is where it is heading.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct TabLabelWrap {
|
||||||
|
/// Current reveal amount, 0 = collapsed, 1 = fully expanded.
|
||||||
|
pub progress: f32,
|
||||||
|
/// Reveal amount the animation is moving towards.
|
||||||
|
pub target: f32,
|
||||||
|
/// `true` keeps the label expanded regardless of interaction —
|
||||||
|
/// the persistent Draw pill, and every button in text-fallback mode.
|
||||||
|
persistent: bool,
|
||||||
|
/// Label length; width is recomputed from the live font size each
|
||||||
|
/// frame so viewport-driven font rescales stay correct.
|
||||||
|
label_chars: usize,
|
||||||
|
/// Last width written to the node, to skip no-op layout writes.
|
||||||
|
last_width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on the label `Text` inside a [`TabLabelWrap`].
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct TabLabel;
|
||||||
|
|
||||||
|
/// Duck-away state on the bar container: while a card drag is committed
|
||||||
|
/// the whole bar slides below the screen edge so it never occludes a
|
||||||
|
/// drop target on the bottom tableau rows, then slides back on release.
|
||||||
|
/// `progress` ∈ [0, 1]; 1 = fully off-screen.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct TabBarDuck {
|
||||||
|
/// Current slide amount, animated by [`duck_tab_bar_during_drag`].
|
||||||
|
pub progress: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extra slide distance past the bar's own height so its drop shadow
|
||||||
|
/// (16 px blur + 6 px offset) also clears the screen edge.
|
||||||
|
const DUCK_SHADOW_ALLOWANCE_PX: f32 = 24.0;
|
||||||
|
|
||||||
|
/// Rasterised icon textures for the five tab-bar actions.
|
||||||
|
pub(super) struct TabIcons {
|
||||||
|
undo: Handle<Image>,
|
||||||
|
draw: Handle<Image>,
|
||||||
|
hint: Handle<Image>,
|
||||||
|
pause: Handle<Image>,
|
||||||
|
menu: Handle<Image>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TabIcons {
|
||||||
|
/// Rasterise all five icons into `images`. Returns `None` (and warns)
|
||||||
|
/// if any icon fails — the caller then falls back to text-only labels,
|
||||||
|
/// so a bad SVG degrades the look, never the playability.
|
||||||
|
pub(super) fn rasterize_into(images: &mut Assets<Image>) -> Option<Self> {
|
||||||
|
let mut add = |icon: HudIcon| match icon.rasterize() {
|
||||||
|
Ok(img) => Some(images.add(img)),
|
||||||
|
Err(e) => {
|
||||||
|
warn!("tab_bar: failed to rasterise {icon:?} icon: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Some(Self {
|
||||||
|
undo: add(HudIcon::Undo)?,
|
||||||
|
draw: add(HudIcon::Draw)?,
|
||||||
|
hint: add(HudIcon::Hint)?,
|
||||||
|
pause: add(HudIcon::Pause)?,
|
||||||
|
menu: add(HudIcon::Menu)?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrapper width in logical px for a label of `chars` monospace glyphs at
|
||||||
|
/// `font_size`, revealed to `progress`. Pure so the animation curve is
|
||||||
|
/// unit-testable without an app.
|
||||||
|
pub(super) fn expansion_width(chars: usize, font_size: f32, progress: f32) -> f32 {
|
||||||
|
progress.clamp(0.0, 1.0) * (LABEL_LEAD_PX + chars as f32 * font_size * FIRA_ADVANCE)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advance `progress` towards `target` by one frame of `dt` seconds.
|
||||||
|
/// Linear tween over `MOTION_SLIDE_SECS`, snapping when reduce-motion is
|
||||||
|
/// active. Pure for the same reason as [`expansion_width`].
|
||||||
|
pub(super) fn step_expansion(progress: f32, target: f32, dt: f32, reduce_motion: bool) -> f32 {
|
||||||
|
if reduce_motion {
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
let max_step = dt / crate::ui_theme::MOTION_SLIDE_SECS;
|
||||||
|
progress + (target - progress).clamp(-max_step, max_step)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the floating glass tab bar: one full-width invisible flex row
|
||||||
|
/// (carrying the `HudActionBar` marker so chrome show/hide keeps working)
|
||||||
|
/// centring the glass pill and the detached circular Menu button.
|
||||||
|
///
|
||||||
|
/// `icons: None` (rasteriser unavailable, e.g. `MinimalPlugins` tests, or
|
||||||
|
/// an SVG regression) switches every button to text-fallback: labels stay
|
||||||
|
/// expanded so the bar remains fully usable without textures.
|
||||||
|
pub(super) fn spawn_glass_tab_bar(
|
||||||
|
commands: &mut Commands,
|
||||||
|
font: &TextFont,
|
||||||
|
icons: Option<&TabIcons>,
|
||||||
|
) {
|
||||||
|
let text_fallback = icons.is_none();
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(TAB_BAR_MARGIN),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
ZIndex(Z_HUD),
|
||||||
|
SafeAreaAnchoredBottom {
|
||||||
|
base_bottom: TAB_BAR_MARGIN,
|
||||||
|
},
|
||||||
|
HudActionBar,
|
||||||
|
TabBarDuck { progress: 0.0 },
|
||||||
|
UiTransform::IDENTITY,
|
||||||
|
))
|
||||||
|
.with_children(|bar| {
|
||||||
|
// The main pill: Undo · [Draw] · Hint · Pause.
|
||||||
|
bar.spawn((
|
||||||
|
Node {
|
||||||
|
height: Val::Px(TAB_BAR_HEIGHT),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
padding: UiRect::axes(Val::Px(SPACE_2), Val::Px(0.0)),
|
||||||
|
column_gap: Val::Px(4.0),
|
||||||
|
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
glass_surface(),
|
||||||
|
))
|
||||||
|
.with_children(|pill| {
|
||||||
|
spawn_tab_button(
|
||||||
|
pill,
|
||||||
|
UndoButton,
|
||||||
|
"Undo",
|
||||||
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||||
|
font,
|
||||||
|
0,
|
||||||
|
icons.map(|i| i.undo.clone()),
|
||||||
|
false,
|
||||||
|
text_fallback,
|
||||||
|
);
|
||||||
|
spawn_tab_button(
|
||||||
|
pill,
|
||||||
|
DrawButton,
|
||||||
|
"Draw",
|
||||||
|
"Draw from the stock — same as tapping the deck.",
|
||||||
|
font,
|
||||||
|
1,
|
||||||
|
icons.map(|i| i.draw.clone()),
|
||||||
|
true,
|
||||||
|
text_fallback,
|
||||||
|
);
|
||||||
|
spawn_tab_button(
|
||||||
|
pill,
|
||||||
|
HintButton,
|
||||||
|
TOUCH_HINT_LABEL,
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
font,
|
||||||
|
2,
|
||||||
|
icons.map(|i| i.hint.clone()),
|
||||||
|
false,
|
||||||
|
text_fallback,
|
||||||
|
);
|
||||||
|
spawn_tab_button(
|
||||||
|
pill,
|
||||||
|
PauseButton,
|
||||||
|
"Pause",
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
font,
|
||||||
|
3,
|
||||||
|
icons.map(|i| i.pause.clone()),
|
||||||
|
false,
|
||||||
|
text_fallback,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The detached circular Menu button, same glass material.
|
||||||
|
bar.spawn((
|
||||||
|
MenuButton,
|
||||||
|
ActionButton,
|
||||||
|
Button,
|
||||||
|
GlassTabButton {
|
||||||
|
idle: GLASS_BTN_IDLE,
|
||||||
|
hovered: GLASS_BTN_HOVER,
|
||||||
|
pressed: GLASS_BTN_PRESSED,
|
||||||
|
},
|
||||||
|
Tooltip::new(
|
||||||
|
"Open Home, Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
),
|
||||||
|
Focusable {
|
||||||
|
group: FocusGroup::Hud,
|
||||||
|
order: 4,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
width: Val::Px(TAB_BAR_HEIGHT),
|
||||||
|
height: Val::Px(TAB_BAR_HEIGHT),
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
border: UiRect::all(Val::Px(GLASS_BORDER_PX)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(TAB_BAR_HEIGHT / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(GLASS_BTN_IDLE),
|
||||||
|
glass_surface(),
|
||||||
|
))
|
||||||
|
.with_children(|circle| {
|
||||||
|
if let Some(icons) = icons {
|
||||||
|
spawn_tab_icon(circle, icons.menu.clone(), TEXT_PRIMARY);
|
||||||
|
} else {
|
||||||
|
// Text fallback: a menu glyph-free label keeps the
|
||||||
|
// button discoverable without any texture.
|
||||||
|
circle.spawn((
|
||||||
|
Text::new("Menu"),
|
||||||
|
TextLayout::new_with_linebreak(LineBreak::NoWrap),
|
||||||
|
font.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns one button inside the pill: icon, then the width-animated label
|
||||||
|
/// wrapper. `active` gives the accent-filled persistent pill (Draw);
|
||||||
|
/// `text_fallback` pins every label open when icons are unavailable.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_tab_button<M: Component>(
|
||||||
|
pill: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
icon: Option<Handle<Image>>,
|
||||||
|
active: bool,
|
||||||
|
text_fallback: bool,
|
||||||
|
) {
|
||||||
|
let (idle, hovered, pressed) = if active {
|
||||||
|
(ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, GLASS_ACTIVE_PRESSED)
|
||||||
|
} else {
|
||||||
|
(GLASS_BTN_IDLE, GLASS_BTN_HOVER, GLASS_BTN_PRESSED)
|
||||||
|
};
|
||||||
|
let icon_tint = if active { TEXT_PRIMARY } else { TEXT_SECONDARY };
|
||||||
|
let persistent = active || text_fallback;
|
||||||
|
let start = if persistent { 1.0 } else { 0.0 };
|
||||||
|
let chars = label.chars().count();
|
||||||
|
|
||||||
|
pill.spawn((
|
||||||
|
marker,
|
||||||
|
ActionButton,
|
||||||
|
Button,
|
||||||
|
GlassTabButton {
|
||||||
|
idle,
|
||||||
|
hovered,
|
||||||
|
pressed,
|
||||||
|
},
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
Focusable {
|
||||||
|
group: FocusGroup::Hud,
|
||||||
|
order,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
height: Val::Px(TAB_BUTTON_SIZE),
|
||||||
|
min_width: Val::Px(TAB_BUTTON_SIZE),
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
justify_content: JustifyContent::Center,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
padding: UiRect::axes(Val::Px(SPACE_3), Val::Px(0.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(TAB_BUTTON_SIZE / 2.0)),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(idle),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
if let Some(handle) = icon {
|
||||||
|
spawn_tab_icon(b, handle, icon_tint);
|
||||||
|
}
|
||||||
|
// The clipping wrapper the animation drives. Label text sits
|
||||||
|
// left-anchored inside, so growing the width reveals it
|
||||||
|
// left-to-right and shrinking clips it from the right.
|
||||||
|
b.spawn((
|
||||||
|
TabLabelWrap {
|
||||||
|
progress: start,
|
||||||
|
target: start,
|
||||||
|
persistent,
|
||||||
|
label_chars: chars,
|
||||||
|
last_width: f32::NAN,
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
overflow: Overflow::clip(),
|
||||||
|
width: Val::Px(expansion_width(chars, font.font_size, start)),
|
||||||
|
height: Val::Percent(100.0),
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
justify_content: JustifyContent::FlexEnd,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|wrap| {
|
||||||
|
let mut color = TEXT_PRIMARY;
|
||||||
|
color.set_alpha(start);
|
||||||
|
wrap.spawn((
|
||||||
|
TabLabel,
|
||||||
|
ActionButtonLabel,
|
||||||
|
Text::new(label),
|
||||||
|
TextLayout::new_with_linebreak(LineBreak::NoWrap),
|
||||||
|
font.clone(),
|
||||||
|
TextColor(color),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One fixed-size tinted icon node.
|
||||||
|
fn spawn_tab_icon(parent: &mut ChildSpawnerCommands, handle: Handle<Image>, tint: Color) {
|
||||||
|
parent.spawn((
|
||||||
|
ImageNode {
|
||||||
|
color: tint,
|
||||||
|
..ImageNode::new(handle)
|
||||||
|
},
|
||||||
|
Node {
|
||||||
|
width: Val::Px(TAB_ICON_PX),
|
||||||
|
height: Val::Px(TAB_ICON_PX),
|
||||||
|
flex_shrink: 0.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets each label wrapper's animation target from its button's
|
||||||
|
/// interaction state: persistent wrappers pin to 1, the rest expand while
|
||||||
|
/// pressed or hovered and collapse on release.
|
||||||
|
pub(super) fn drive_tab_expansion(
|
||||||
|
buttons: Query<(&Interaction, &Children), With<GlassTabButton>>,
|
||||||
|
mut wraps: Query<&mut TabLabelWrap>,
|
||||||
|
) {
|
||||||
|
for (interaction, children) in &buttons {
|
||||||
|
for child in children.iter() {
|
||||||
|
let Ok(mut wrap) = wraps.get_mut(child) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let target = if wrap.persistent || !matches!(interaction, Interaction::None) {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
// Bypass change detection on no-ops so `animate_tab_expansion`
|
||||||
|
// and layout stay idle while nothing moves.
|
||||||
|
if wrap.target != target {
|
||||||
|
wrap.target = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every label reveal towards its target and writes the wrapper
|
||||||
|
/// width + label alpha. Width is derived from the label's *live* font size,
|
||||||
|
/// so the Fold's posture-change font rescale re-widens expanded labels
|
||||||
|
/// automatically. Node writes are skipped when the width is unchanged to
|
||||||
|
/// avoid dirtying UI layout every frame.
|
||||||
|
pub(super) fn animate_tab_expansion(
|
||||||
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
mut wraps: Query<(&mut TabLabelWrap, &mut Node, &Children)>,
|
||||||
|
mut labels: Query<(&TextFont, &mut TextColor), With<TabLabel>>,
|
||||||
|
) {
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (mut wrap, mut node, children) in &mut wraps {
|
||||||
|
for child in children.iter() {
|
||||||
|
let Ok((font, mut text_color)) = labels.get_mut(child) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let next = step_expansion(wrap.progress, wrap.target, dt, reduce_motion);
|
||||||
|
let width = expansion_width(wrap.label_chars, font.font_size, next);
|
||||||
|
// `!=` (not epsilon) is deliberate: last_width starts NaN so the
|
||||||
|
// first frame always writes, and equal floats mean a true no-op.
|
||||||
|
if next != wrap.progress || width != wrap.last_width {
|
||||||
|
wrap.progress = next;
|
||||||
|
wrap.last_width = width;
|
||||||
|
node.width = Val::Px(width);
|
||||||
|
text_color.0.set_alpha(next);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Slides the bar off the bottom edge while a card drag is committed and
|
||||||
|
/// back once it ends. Effectively touch-only: the desktop docked bar
|
||||||
|
/// never carries [`TabBarDuck`], so the query is empty there.
|
||||||
|
///
|
||||||
|
/// The slide animates `UiTransform::translation` (a render-time offset,
|
||||||
|
/// +y = down) rather than `Node::bottom`, for two reasons: layout is
|
||||||
|
/// never dirtied mid-drag (the whole point is not to cost frames while
|
||||||
|
/// the player is dragging), and `Node::bottom` is owned by the safe-area
|
||||||
|
/// anchor system — two writers on one field would fight.
|
||||||
|
pub(super) fn duck_tab_bar_during_drag(
|
||||||
|
time: Res<Time>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
drag: Option<Res<DragState>>,
|
||||||
|
insets: Option<Res<SafeAreaInsets>>,
|
||||||
|
windows: Query<&Window>,
|
||||||
|
mut bars: Query<(&mut TabBarDuck, &mut UiTransform), With<HudActionBar>>,
|
||||||
|
) {
|
||||||
|
let target = if drag.as_deref().is_some_and(|d| d.committed) {
|
||||||
|
1.0
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
let reduce_motion = settings.as_deref().is_some_and(|s| s.0.reduce_motion_mode);
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
// Physical-px inset → logical, same conversion the safe-area anchors
|
||||||
|
// apply (CLAUDE.md §10): the bar sits `inset` above its base position,
|
||||||
|
// so the slide must cover that extra travel to fully clear the edge.
|
||||||
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
|
let inset_logical = insets.as_deref().map_or(0.0, |i| i.bottom / scale);
|
||||||
|
let slide_px = TAB_BAR_CLEARANCE_PX + inset_logical + DUCK_SHADOW_ALLOWANCE_PX;
|
||||||
|
for (mut duck, mut transform) in &mut bars {
|
||||||
|
let next = step_expansion(duck.progress, target, dt, reduce_motion);
|
||||||
|
if next == duck.progress && duck.progress == target {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
duck.progress = next;
|
||||||
|
transform.translation = Val2::px(0.0, next * slide_px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Idle / hover / pressed feedback for glass tab buttons — the glass
|
||||||
|
/// counterpart of `paint_action_buttons`, using each button's own colour
|
||||||
|
/// triple so the accent-filled active pill and the transparent icon
|
||||||
|
/// buttons share one system.
|
||||||
|
pub(super) fn paint_glass_tab_buttons(
|
||||||
|
mut buttons: Query<(&Interaction, &GlassTabButton, &mut BackgroundColor), Changed<Interaction>>,
|
||||||
|
) {
|
||||||
|
for (interaction, style, mut bg) in &mut buttons {
|
||||||
|
bg.0 = match interaction {
|
||||||
|
Interaction::Pressed => style.pressed,
|
||||||
|
Interaction::Hovered => style.hovered,
|
||||||
|
Interaction::None => style.idle,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -907,6 +907,10 @@ fn hud_focus_only_engages_when_button_hovered() {
|
|||||||
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
|
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
|
||||||
/// buttons the tested spawn function creates — `headless_app` would
|
/// buttons the tested spawn function creates — `headless_app` would
|
||||||
/// pre-spawn the platform-default bar and pollute the counts.
|
/// pre-spawn the platform-default bar and pollute the counts.
|
||||||
|
///
|
||||||
|
/// The touch branch spawns the glass tab bar with `icons: None`
|
||||||
|
/// (`MinimalPlugins` has no `Assets<Image>`), which exercises the
|
||||||
|
/// text-fallback path — same markers, labels pinned expanded.
|
||||||
fn bar_only_app(touch: bool) -> App {
|
fn bar_only_app(touch: bool) -> App {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
app.add_plugins(MinimalPlugins);
|
app.add_plugins(MinimalPlugins);
|
||||||
@@ -914,13 +918,13 @@ fn bar_only_app(touch: bool) -> App {
|
|||||||
let font = TextFont::default();
|
let font = TextFont::default();
|
||||||
let world = app.world_mut();
|
let world = app.world_mut();
|
||||||
let mut commands = world.commands();
|
let mut commands = world.commands();
|
||||||
commands.spawn(Node::default()).with_children(|row| {
|
|
||||||
if touch {
|
if touch {
|
||||||
spawn_touch_action_bar(row, &font);
|
spawn_glass_tab_bar(&mut commands, &font, None);
|
||||||
} else {
|
} else {
|
||||||
|
commands.spawn(Node::default()).with_children(|row| {
|
||||||
spawn_desktop_action_bar(row, &font);
|
spawn_desktop_action_bar(row, &font);
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
app.update();
|
app.update();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
@@ -933,7 +937,7 @@ fn count_buttons<C: Component>(app: &mut App) -> usize {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
|
fn touch_tab_bar_has_thumb_trio_plus_menu_and_pause() {
|
||||||
let mut app = bar_only_app(true);
|
let mut app = bar_only_app(true);
|
||||||
|
|
||||||
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
|
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
|
||||||
@@ -949,6 +953,130 @@ fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
|
|||||||
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
|
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The glass bar floats: its container must carry a bottom margin (not be
|
||||||
|
/// docked at 0) both before and after safe-area insets arrive.
|
||||||
|
#[test]
|
||||||
|
fn glass_tab_bar_floats_above_bottom_edge() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query_filtered::<(&Node, &SafeAreaAnchoredBottom), With<HudActionBar>>();
|
||||||
|
let (node, anchor) = q.single(world).expect("glass bar container exists");
|
||||||
|
assert!(anchor.base_bottom > 0.0, "bar must float, not dock");
|
||||||
|
assert_eq!(node.bottom, Val::Px(anchor.base_bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Draw is the persistently-expanded active pill (user decision
|
||||||
|
/// 2026-07-15); with `icons: None` every wrapper is pinned expanded via
|
||||||
|
/// text fallback, so instead assert on the *persistent* flag's effect:
|
||||||
|
/// all wrappers spawn at full reveal and target full reveal.
|
||||||
|
#[test]
|
||||||
|
fn glass_tab_bar_text_fallback_pins_labels_expanded() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query::<&TabLabelWrap>();
|
||||||
|
let wraps: Vec<_> = q.iter(world).collect();
|
||||||
|
assert_eq!(wraps.len(), 4, "Undo/Draw/Hint/Pause each carry a wrapper");
|
||||||
|
for wrap in wraps {
|
||||||
|
assert_eq!(wrap.progress, 1.0);
|
||||||
|
assert_eq!(wrap.target, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Focus order must match visual reading order: pill buttons left to
|
||||||
|
/// right, then the detached Menu circle.
|
||||||
|
#[test]
|
||||||
|
fn glass_tab_bar_focus_order_reads_left_to_right() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
|
||||||
|
for (f, expected) in [
|
||||||
|
(focusable_for::<UndoButton>(&mut app), 0),
|
||||||
|
(focusable_for::<DrawButton>(&mut app), 1),
|
||||||
|
(focusable_for::<HintButton>(&mut app), 2),
|
||||||
|
(focusable_for::<PauseButton>(&mut app), 3),
|
||||||
|
(focusable_for::<MenuButton>(&mut app), 4),
|
||||||
|
] {
|
||||||
|
assert_eq!(f.group, FocusGroup::Hud);
|
||||||
|
assert_eq!(f.order, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `expansion_width` is the single source of truth for the label reveal:
|
||||||
|
/// zero when collapsed, lead + monospace advance when expanded, linear
|
||||||
|
/// in between.
|
||||||
|
#[test]
|
||||||
|
fn expansion_width_is_linear_in_progress() {
|
||||||
|
assert_eq!(expansion_width(4, 16.0, 0.0), 0.0);
|
||||||
|
let full = expansion_width(4, 16.0, 1.0);
|
||||||
|
assert!(full > 4.0 * 16.0 * 0.62, "full width includes the lead gap");
|
||||||
|
let half = expansion_width(4, 16.0, 0.5);
|
||||||
|
assert!((half - full / 2.0).abs() < f32::EPSILON * 100.0);
|
||||||
|
// Out-of-range progress clamps rather than extrapolating.
|
||||||
|
assert_eq!(expansion_width(4, 16.0, 1.5), full);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// While a committed drag is active the bar must slide down (positive
|
||||||
|
/// `UiTransform` y — off-screen) and return to identity once the drag
|
||||||
|
/// ends. Manual time steps make the tween deterministic.
|
||||||
|
#[test]
|
||||||
|
fn tab_bar_ducks_during_committed_drag_and_returns() {
|
||||||
|
use crate::resources::DragState;
|
||||||
|
use bevy::ui::UiTransform;
|
||||||
|
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
set_manual_time_step(&mut app, 0.05);
|
||||||
|
app.add_systems(Update, duck_tab_bar_during_drag);
|
||||||
|
app.insert_resource(DragState {
|
||||||
|
committed: true,
|
||||||
|
..default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let translation_y_px = |app: &mut App| -> f32 {
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut q = world.query_filtered::<&UiTransform, With<HudActionBar>>();
|
||||||
|
let t = q.single(world).expect("bar carries UiTransform");
|
||||||
|
match t.translation.y {
|
||||||
|
Val::Px(px) => px,
|
||||||
|
other => panic!("duck must write Val::Px, got {other:?}"),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 0.18s slide at 0.05s/frame: fully ducked within 5 frames.
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
let mid = translation_y_px(&mut app);
|
||||||
|
assert!(mid > 0.0, "bar must start sliding down: {mid}");
|
||||||
|
for _ in 0..5 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
let ducked = translation_y_px(&mut app);
|
||||||
|
assert!(
|
||||||
|
ducked > TAB_BAR_CLEARANCE_PX,
|
||||||
|
"fully ducked bar must clear its own floating footprint: {ducked}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drag ends → bar slides back to identity.
|
||||||
|
app.world_mut().resource_mut::<DragState>().committed = false;
|
||||||
|
for _ in 0..8 {
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
assert_eq!(translation_y_px(&mut app), 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The tween must move at the `MOTION_SLIDE_SECS` rate, never overshoot,
|
||||||
|
/// and snap instantly under reduce-motion.
|
||||||
|
#[test]
|
||||||
|
fn step_expansion_tweens_and_respects_reduce_motion() {
|
||||||
|
// One 60 fps frame covers 1/(0.18*60) ≈ 9.3% of the reveal.
|
||||||
|
let one_frame = step_expansion(0.0, 1.0, 1.0 / 60.0, false);
|
||||||
|
assert!(one_frame > 0.0 && one_frame < 0.2);
|
||||||
|
// A huge dt clamps at the target instead of overshooting.
|
||||||
|
assert_eq!(step_expansion(0.0, 1.0, 10.0, false), 1.0);
|
||||||
|
// Collapse works symmetrically.
|
||||||
|
assert!(step_expansion(1.0, 0.0, 1.0 / 60.0, false) < 1.0);
|
||||||
|
// Reduce-motion snaps regardless of dt.
|
||||||
|
assert_eq!(step_expansion(0.0, 1.0, 0.0, true), 1.0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
|
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
|
||||||
let mut app = bar_only_app(false);
|
let mut app = bar_only_app(false);
|
||||||
|
|||||||
@@ -573,20 +573,6 @@ pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Metrics for the touch bar's enlarged Undo / Draw / Hint trio (Phase F).
|
|
||||||
/// 96×64 px targets — comfortably past the 44 px accessibility floor and
|
|
||||||
/// big enough to hit one-handed without looking. Only the touch layout
|
|
||||||
/// spawns primary buttons, so no desktop branch is needed; the desktop
|
|
||||||
/// values exist purely so host-side tests can exercise the touch spawn
|
|
||||||
/// path with sensible numbers.
|
|
||||||
pub(super) fn primary_action_button_metrics() -> (UiRect, Val, Val) {
|
|
||||||
(
|
|
||||||
UiRect::axes(Val::Px(10.0), Val::Px(8.0)),
|
|
||||||
Val::Px(96.0),
|
|
||||||
Val::Px(64.0),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(super) fn spawn_action_button_label(
|
pub(super) fn spawn_action_button_label(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
label: &str,
|
label: &str,
|
||||||
|
|||||||
@@ -58,6 +58,14 @@ use crate::touch_selection_plugin::TouchSelectionState;
|
|||||||
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
|
||||||
use solitaire_core::DrawStockConfig;
|
use solitaire_core::DrawStockConfig;
|
||||||
|
|
||||||
|
/// Set wrapping the core pointer/keyboard input chain (clicks, drags, touch,
|
||||||
|
/// hotkeys). Runs between [`crate::game_plugin::PreMutation`] (it reads the
|
||||||
|
/// ticked game state) and [`crate::selection_plugin::SelectionKeySet`] (the
|
||||||
|
/// selection keys check `DragState::is_idle`); request events it emits are
|
||||||
|
/// consumed at `GameMutation` in the same frame (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PointerInput;
|
||||||
|
|
||||||
/// System-set labels used to anchor external systems relative to the touch
|
/// System-set labels used to anchor external systems relative to the touch
|
||||||
/// drag pipeline without duplicating the internal chain ordering.
|
/// drag pipeline without duplicating the internal chain ordering.
|
||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
@@ -143,7 +151,13 @@ impl Plugin for InputPlugin {
|
|||||||
// `.chain()` still orders them relative to each other.
|
// `.chain()` still orders them relative to each other.
|
||||||
handle_keyboard_core
|
handle_keyboard_core
|
||||||
.in_set(crate::game_plugin::DrawRequestWriters)
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
|
||||||
|
// N/Z/X/… and U also feed the shared new-game and
|
||||||
|
// undo request streams (#143).
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters),
|
||||||
handle_keyboard_hint,
|
handle_keyboard_hint,
|
||||||
handle_keyboard_forfeit,
|
handle_keyboard_forfeit,
|
||||||
handle_stock_click
|
handle_stock_click
|
||||||
@@ -159,29 +173,61 @@ impl Plugin for InputPlugin {
|
|||||||
end_drag.before(GameMutation),
|
end_drag.before(GameMutation),
|
||||||
// Touch drag pipeline (parallel path through DragState).
|
// Touch drag pipeline (parallel path through DragState).
|
||||||
touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
|
touch_start_drag.in_set(TouchDragSet::AfterStartDrag),
|
||||||
touch_follow_drag,
|
// Moves the dragged card's Transform; UiTextFx members
|
||||||
|
// animate chrome entities only — disjoint domains Bevy
|
||||||
|
// can't prove (#143).
|
||||||
|
touch_follow_drag.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
|
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
|
||||||
touch_end_drag
|
touch_end_drag
|
||||||
.after(TouchDragSet::BeforeEndDrag)
|
.after(TouchDragSet::BeforeEndDrag)
|
||||||
.before(GameMutation),
|
.before(GameMutation),
|
||||||
|
// Reads StateChangedEvent via cursor — a reset one frame
|
||||||
|
// after the mutation is invisible (the cycle index only
|
||||||
|
// matters on the next H press), so it rides at the end
|
||||||
|
// of the input chain instead of chasing every
|
||||||
|
// StateChanged writer (#143).
|
||||||
|
// It also drains the new-game request stream via its own
|
||||||
|
// cursor, so it joins the writer club the same way
|
||||||
|
// enqueue_toasts does (#143).
|
||||||
|
reset_hint_cycle_on_state_change
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.in_set(PointerInput),
|
||||||
)
|
)
|
||||||
.add_systems(Update, reset_hint_cycle_on_state_change);
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
PointerInput
|
||||||
|
.after(crate::game_plugin::PreMutation)
|
||||||
|
.before(crate::selection_plugin::SelectionKeySet)
|
||||||
|
.before(crate::ui_focus::FocusKeys),
|
||||||
|
);
|
||||||
// F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
|
// F11 fullscreen toggle is desktop-only; Android windows are always full-screen.
|
||||||
|
// It writes only `Window.mode`; no system reads the mode, and the
|
||||||
|
// geometry change it triggers arrives via WindowResized events on a
|
||||||
|
// later frame — order against Window readers is provably irrelevant
|
||||||
|
// (#143).
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
app.add_systems(Update, handle_fullscreen);
|
app.add_systems(Update, handle_fullscreen.ambiguous_with_all());
|
||||||
app
|
app
|
||||||
// Async hint pipeline: state-change drop runs before the
|
// Async hint pipeline: state-change drop runs before the
|
||||||
// poll system so a move applied this frame cancels any
|
// poll system so a move applied this frame cancels any
|
||||||
// in-flight task before its result can be surfaced.
|
// in-flight task before its result can be surfaced. The poll
|
||||||
|
// emits the hint toast and the pile-highlight event the board
|
||||||
|
// painters consume this frame (#143).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
crate::pending_hint::drop_pending_hint_on_state_change,
|
crate::pending_hint::drop_pending_hint_on_state_change,
|
||||||
crate::pending_hint::poll_pending_hint_task,
|
crate::pending_hint::poll_pending_hint_task
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.in_set(crate::pending_hint::PendingHint)
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::auto_complete_plugin::AutoComplete),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +261,7 @@ fn handle_keyboard_core(
|
|||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
progress: Option<Res<ProgressResource>>,
|
progress: Option<Res<ProgressResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
mut ev: CoreKeyboardMessages<'_>,
|
mut ev: CoreKeyboardMessages<'_>,
|
||||||
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
mut time_attack: Option<ResMut<TimeAttackResource>>,
|
||||||
selection: Option<Res<SelectionState>>,
|
selection: Option<Res<SelectionState>>,
|
||||||
@@ -227,6 +274,19 @@ fn handle_keyboard_core(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mutual exclusion with pointer drags — mirrors `handle_selection_keys`.
|
||||||
|
// Undo / draw / new-game during an active mouse or touch drag would fire
|
||||||
|
// a StateChangedEvent whose card re-sync inserts a `CardAnim` on the
|
||||||
|
// dragged cards, fighting `follow_drag`'s per-frame Transform writes and
|
||||||
|
// leaving `DragState` origin indices stale against the mutated state.
|
||||||
|
// The keyboard-drag sentinel may proceed: `clear_selection_on_state_change`
|
||||||
|
// already drops that lift cleanly when the state moves.
|
||||||
|
if !drag.is_idle()
|
||||||
|
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// During replay playback (Playing or Completed) all game-input shortcuts
|
// During replay playback (Playing or Completed) all game-input shortcuts
|
||||||
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
// are suppressed. The replay overlay owns Space (pause/resume) and the
|
||||||
// arrow keys (step). Letting game input through would mutate
|
// arrow keys (step). Letting game input through would mutate
|
||||||
@@ -473,11 +533,19 @@ pub fn emit_hint_visuals(
|
|||||||
fn handle_keyboard_forfeit(
|
fn handle_keyboard_forfeit(
|
||||||
keys: Res<ButtonInput<KeyCode>>,
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
paused: Option<Res<PausedResource>>,
|
paused: Option<Res<PausedResource>>,
|
||||||
|
drag: Res<DragState>,
|
||||||
mut requests: MessageWriter<ForfeitRequestEvent>,
|
mut requests: MessageWriter<ForfeitRequestEvent>,
|
||||||
) {
|
) {
|
||||||
if paused.is_some_and(|p| p.0) {
|
if paused.is_some_and(|p| p.0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Same pointer-drag exclusion as `handle_keyboard_core` — forfeiting
|
||||||
|
// mid-drag would mutate the game state under the dragged cards.
|
||||||
|
if !drag.is_idle()
|
||||||
|
&& drag.active_touch_id != Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
if !keys.just_pressed(KeyCode::KeyG) {
|
if !keys.just_pressed(KeyCode::KeyG) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -817,3 +817,123 @@ fn pressing_h_spawns_pending_hint_task() {
|
|||||||
"pressing H must spawn an async hint task",
|
"pressing H must spawn an async hint task",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Pointer-drag / keyboard mutual exclusion.
|
||||||
|
//
|
||||||
|
// A state mutation fired while `follow_drag` is writing the dragged cards'
|
||||||
|
// transforms every frame would make the card re-sync insert a `CardAnim`
|
||||||
|
// on the same entities (two systems fighting over `Transform`) and leave
|
||||||
|
// `DragState`'s origin indices stale. `handle_keyboard_core` and
|
||||||
|
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
|
||||||
|
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
|
||||||
|
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
|
||||||
|
// drops that lift cleanly when the state moves.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn keyboard_core_app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<UndoRequestEvent>();
|
||||||
|
app.add_message::<NewGameRequestEvent>();
|
||||||
|
app.add_message::<InfoToastEvent>();
|
||||||
|
app.add_message::<DrawRequestEvent>();
|
||||||
|
app.add_message::<StartZenRequestEvent>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.add_systems(Update, handle_keyboard_core);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn press_key(app: &mut App, key: KeyCode) {
|
||||||
|
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||||
|
input.release(key);
|
||||||
|
input.clear();
|
||||||
|
input.press(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn message_count<M: Message>(app: &App) -> usize {
|
||||||
|
let messages = app.world().resource::<Messages<M>>();
|
||||||
|
let mut cursor = messages.get_cursor();
|
||||||
|
cursor.read(messages).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
|
||||||
|
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||||
|
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
|
||||||
|
drag.committed = true;
|
||||||
|
drag.active_touch_id = active_touch_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_fires_undo_when_no_drag_active() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
1,
|
||||||
|
"U with an idle DragState must fire UndoRequestEvent",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_ignored_during_committed_mouse_drag() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(&mut app, None); // mouse drag
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"U during a mouse drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_key_ignored_during_touch_drag() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(&mut app, Some(7)); // real touch id
|
||||||
|
press_key(&mut app, KeyCode::KeyD);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<DrawRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"D during a touch drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn u_key_allowed_during_keyboard_lift() {
|
||||||
|
let mut app = keyboard_core_app();
|
||||||
|
set_drag(
|
||||||
|
&mut app,
|
||||||
|
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
|
||||||
|
);
|
||||||
|
press_key(&mut app, KeyCode::KeyU);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<UndoRequestEvent>(&app),
|
||||||
|
1,
|
||||||
|
"the keyboard-lift sentinel must not block core shortcuts",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn g_key_forfeit_ignored_during_mouse_drag() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.add_message::<ForfeitRequestEvent>();
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.init_resource::<DragState>();
|
||||||
|
app.add_systems(Update, handle_keyboard_forfeit);
|
||||||
|
|
||||||
|
set_drag(&mut app, None);
|
||||||
|
press_key(&mut app, KeyCode::KeyG);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
message_count::<ForfeitRequestEvent>(&app),
|
||||||
|
0,
|
||||||
|
"G during a mouse drag must be swallowed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
use crate::sync_plugin::SyncProviderResource;
|
use crate::sync_plugin::SyncProviderResource;
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||||
spawn_modal_button, spawn_modal_header,
|
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, STATE_INFO, TEXT_DISABLED, TEXT_PRIMARY,
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_SUBTLE, RADIUS_SM, TEXT_DISABLED, TEXT_PRIMARY,
|
||||||
TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, Z_MODAL_PANEL,
|
||||||
Z_MODAL_PANEL, Z_PAUSE_DIALOG,
|
Z_PAUSE_DIALOG,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -158,18 +158,38 @@ impl Plugin for LeaderboardPlugin {
|
|||||||
poll_leaderboard_fetch,
|
poll_leaderboard_fetch,
|
||||||
update_leaderboard_panel,
|
update_leaderboard_panel,
|
||||||
handle_opt_in_button,
|
handle_opt_in_button,
|
||||||
poll_opt_in_task,
|
// The polls share the warning-toast stream with the
|
||||||
|
// daily-expiry check (drained wholesale, #143).
|
||||||
|
poll_opt_in_task
|
||||||
|
.in_set(crate::game_plugin::WarningToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::WarningToastWriters),
|
||||||
handle_opt_out_button,
|
handle_opt_out_button,
|
||||||
poll_opt_out_task,
|
poll_opt_out_task
|
||||||
|
.in_set(crate::game_plugin::WarningToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::WarningToastWriters),
|
||||||
handle_set_display_name_button,
|
handle_set_display_name_button,
|
||||||
handle_display_name_text_input,
|
handle_display_name_text_input,
|
||||||
handle_display_name_confirm,
|
handle_display_name_confirm,
|
||||||
handle_display_name_cancel,
|
handle_display_name_cancel,
|
||||||
update_leaderboard_public_name_label,
|
// Writes one marker-filtered Text label — disjoint from
|
||||||
|
// the HUD/stats text painters (UiTextFx club, #143).
|
||||||
|
update_leaderboard_public_name_label
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
// Opt-in state and display name live in
|
||||||
|
// SettingsResource — the whole chain joins the
|
||||||
|
// screen-plugin settings club (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.add_systems(Update, scroll_leaderboard_panel);
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
scroll_leaderboard_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -536,11 +556,6 @@ fn spawn_leaderboard_screen(
|
|||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let font_status = TextFont {
|
|
||||||
font: font_handle.clone(),
|
|
||||||
font_size: TYPE_BODY_LG,
|
|
||||||
..default()
|
|
||||||
};
|
|
||||||
let font_row = TextFont {
|
let font_row = TextFont {
|
||||||
font: font_handle.clone(),
|
font: font_handle.clone(),
|
||||||
font_size: TYPE_BODY,
|
font_size: TYPE_BODY,
|
||||||
@@ -647,30 +662,31 @@ fn spawn_leaderboard_screen(
|
|||||||
.with_children(|body| {
|
.with_children(|body| {
|
||||||
match data {
|
match data {
|
||||||
LeaderboardResource::Idle => {
|
LeaderboardResource::Idle => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Fetching\u{2026}"),
|
body,
|
||||||
font_status.clone(),
|
"\u{21BB}",
|
||||||
TextColor(STATE_INFO),
|
"Fetching the leaderboard\u{2026}",
|
||||||
));
|
None,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
LeaderboardResource::Error(_) => {
|
LeaderboardResource::Error(_) => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Couldn't reach the leaderboard. Try again later."),
|
body,
|
||||||
font_status.clone(),
|
"!",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Couldn't reach the leaderboard.",
|
||||||
));
|
Some("Try again later."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
LeaderboardResource::Loaded(rows) if rows.is_empty() => {
|
||||||
body.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Be the first on the leaderboard."),
|
body,
|
||||||
font_status.clone(),
|
"\u{2660}",
|
||||||
TextColor(TEXT_PRIMARY),
|
"Be the first on the leaderboard.",
|
||||||
));
|
Some("Win a game and opt in to appear here."),
|
||||||
body.spawn((
|
font_res,
|
||||||
Text::new("Win a game and opt in to appear here."),
|
);
|
||||||
font_row.clone(),
|
|
||||||
TextColor(TEXT_SECONDARY),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
LeaderboardResource::Loaded(rows) => {
|
LeaderboardResource::Loaded(rows) => {
|
||||||
// Column headers
|
// Column headers
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ pub mod theme_store_plugin;
|
|||||||
pub mod time_attack_plugin;
|
pub mod time_attack_plugin;
|
||||||
pub mod touch_selection_plugin;
|
pub mod touch_selection_plugin;
|
||||||
pub mod ui_focus;
|
pub mod ui_focus;
|
||||||
|
pub mod ui_glass;
|
||||||
pub mod ui_modal;
|
pub mod ui_modal;
|
||||||
pub mod ui_theme;
|
pub mod ui_theme;
|
||||||
pub mod ui_tooltip;
|
pub mod ui_tooltip;
|
||||||
|
|||||||
@@ -98,11 +98,25 @@ pub struct OnboardingPlugin;
|
|||||||
|
|
||||||
impl Plugin for OnboardingPlugin {
|
impl Plugin for OnboardingPlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<OnboardingSlideIndex>()
|
app.init_resource::<OnboardingSlideIndex>().add_systems(
|
||||||
.add_systems(Update, spawn_if_first_run)
|
|
||||||
.add_systems(
|
|
||||||
Update,
|
Update,
|
||||||
(handle_onboarding_buttons, handle_onboarding_keyboard).chain(),
|
// One chain: all three touch the slide index and the
|
||||||
|
// first-run flag in SettingsResource. Club membership
|
||||||
|
// declares the settings access disjoint from the other
|
||||||
|
// screen plugins'; the keyboard handler runs before
|
||||||
|
// FocusKeys like every other Esc consumer (#143).
|
||||||
|
(
|
||||||
|
spawn_if_first_run,
|
||||||
|
handle_onboarding_buttons,
|
||||||
|
handle_onboarding_keyboard.before(crate::ui_focus::FocusKeys),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
// Before the settings spine: the first-run flag write
|
||||||
|
// lands before every same-frame settings reader (they
|
||||||
|
// all sit at or after SettingsMutation transitively).
|
||||||
|
.before(crate::settings_plugin::SettingsMutation)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,15 @@ pub fn draw_mode_label(mode: DrawStockConfig) -> &'static str {
|
|||||||
/// to disk, and routes the Forfeit confirm-modal flow.
|
/// to disk, and routes the Forfeit confirm-modal flow.
|
||||||
pub struct PausePlugin;
|
pub struct PausePlugin;
|
||||||
|
|
||||||
|
/// Set wrapping every system that mutates [`PausedResource`] or drives the
|
||||||
|
/// pause/forfeit modal flow. Runs as one deterministic chain between
|
||||||
|
/// [`crate::settings_plugin::SettingsMutation`] (the pause overlay's
|
||||||
|
/// draw-mode toggle writes `SettingsResource`) and
|
||||||
|
/// [`crate::game_plugin::GameMutation`], so every system ordered after
|
||||||
|
/// `GameMutation` observes this frame's pause state transitively (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PauseMutation;
|
||||||
|
|
||||||
impl Plugin for PausePlugin {
|
impl Plugin for PausePlugin {
|
||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
// add_message is idempotent — other plugins may register these
|
// add_message is idempotent — other plugins may register these
|
||||||
@@ -116,28 +125,55 @@ impl Plugin for PausePlugin {
|
|||||||
.add_message::<crate::events::ShowSolutionRequestEvent>()
|
.add_message::<crate::events::ShowSolutionRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
.init_resource::<PausedResource>()
|
.init_resource::<PausedResource>()
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
PauseMutation
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
// Before the pre-mutation game-state spine: the elapsed
|
||||||
|
// timer must observe this frame's pause transition, and
|
||||||
|
// the draw-mode toggle's settings write must land before
|
||||||
|
// sync_settings_to_game forwards it.
|
||||||
|
.before(crate::game_plugin::PreMutation)
|
||||||
|
.before(crate::game_plugin::GameMutation)
|
||||||
|
// toggle_pause must see SelectionState *before*
|
||||||
|
// handle_selection_keys clears it, so it can skip Escape
|
||||||
|
// when a card is selected. The other members mutate
|
||||||
|
// PausedResource, which the selection keys read.
|
||||||
|
.before(SelectionKeySet)
|
||||||
|
// HUD buttons read PausedResource (e.g. hint is inert
|
||||||
|
// while paused) — order them after this frame's pause
|
||||||
|
// transition.
|
||||||
|
.before(crate::hud_plugin::HudButtons),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
// toggle_pause must see SelectionState *before* handle_selection_keys
|
// toggle_pause must run *before* handle_forfeit_keyboard
|
||||||
// clears it, so it can skip Escape when a card is selected.
|
// so the ForfeitConfirmScreen is still alive when
|
||||||
// It must also run *before* handle_forfeit_keyboard so the
|
// toggle_pause's early-return guard checks for it —
|
||||||
// ForfeitConfirmScreen is still alive when toggle_pause's
|
// otherwise an Esc that closes the forfeit modal would
|
||||||
// early-return guard checks for it — otherwise an Esc that
|
// also open pause in the same frame. The chain preserves
|
||||||
// closes the forfeit modal would also open pause in the
|
// that edge and makes the whole pause/forfeit flow
|
||||||
// same frame.
|
// deterministic (all members share PausedResource and
|
||||||
|
// the forfeit message streams).
|
||||||
|
// Reads only motion/display settings fields — disjoint
|
||||||
|
// from the screen-plugin club's writes (#143).
|
||||||
toggle_pause
|
toggle_pause
|
||||||
.before(SelectionKeySet)
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
.before(handle_forfeit_keyboard),
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
handle_pause_draw_buttons,
|
auto_resume_on_overlay,
|
||||||
|
handle_pause_draw_buttons
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
handle_pause_resume_button,
|
handle_pause_resume_button,
|
||||||
handle_pause_forfeit_button,
|
handle_pause_forfeit_button,
|
||||||
handle_pause_solution_button,
|
handle_pause_solution_button,
|
||||||
handle_forfeit_request,
|
handle_forfeit_request,
|
||||||
handle_forfeit_confirm_buttons,
|
|
||||||
handle_forfeit_keyboard,
|
handle_forfeit_keyboard,
|
||||||
auto_resume_on_overlay,
|
handle_forfeit_confirm_buttons,
|
||||||
),
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(PauseMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ use crate::resources::{GameStateResource, HintCycleIndex};
|
|||||||
/// emits the hint visuals once the task completes. At most one task
|
/// emits the hint visuals once the task completes. At most one task
|
||||||
/// is ever in flight: a fresh H press while a previous task is
|
/// is ever in flight: a fresh H press while a previous task is
|
||||||
/// running drops the previous task and queues the new one.
|
/// running drops the previous task and queues the new one.
|
||||||
|
/// Set wrapping the async hint pipeline (state-change drop → poll). The
|
||||||
|
/// HUD hint button orders `.before` it so a press cancels/spawns before
|
||||||
|
/// this frame's poll; the drag-highlight painters order `.after` it so
|
||||||
|
/// the hint sprite writes are settled (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PendingHint;
|
||||||
|
|
||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
pub struct PendingHintTask {
|
pub struct PendingHintTask {
|
||||||
/// `Some` while the solver is still working on a verdict.
|
/// `Some` while the solver is still working on a verdict.
|
||||||
|
|||||||
@@ -115,10 +115,27 @@ impl Plugin for PlayBySeedPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
handle_open_dialog,
|
handle_open_dialog,
|
||||||
handle_text_input,
|
// Writes the marker-filtered seed-dialog Text nodes —
|
||||||
tick_debounce_and_spawn_solver_task,
|
// disjoint from the other text painters (UiTextFx
|
||||||
poll_solver_task,
|
// club, #143).
|
||||||
handle_confirm,
|
handle_text_input
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
tick_debounce_and_spawn_solver_task
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
// Reads deal settings for the solver spawn; a frame
|
||||||
|
// of lag is invisible (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
poll_solver_task
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
// Shares the new-game request stream with the other
|
||||||
|
// writers (drained wholesale at GameMutation, #143).
|
||||||
|
handle_confirm
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
handle_cancel,
|
handle_cancel,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
|
|||||||
@@ -60,6 +60,14 @@ pub struct DailyCalendarDot {
|
|||||||
/// [`spawn_profile_body`] for this tab's content.
|
/// [`spawn_profile_body`] for this tab's content.
|
||||||
pub struct ProfilePlugin;
|
pub struct ProfilePlugin;
|
||||||
|
|
||||||
|
/// Self-ambiguous club for writers of
|
||||||
|
/// [`crate::events::ToggleProfileRequestEvent`] (HUD avatar button, home
|
||||||
|
/// profile chip, HUD menu option). The You-hub reader drains the whole
|
||||||
|
/// queue, so writer append order is irrelevant; `you_hub_plugin` orders
|
||||||
|
/// its open/tab handling after the club (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ProfileToggleWriters;
|
||||||
|
|
||||||
/// Marker on the scrollable body Node inside the Profile modal.
|
/// Marker on the scrollable body Node inside the Profile modal.
|
||||||
///
|
///
|
||||||
/// The Profile panel renders sync info, progression (incl. 14-day
|
/// The Profile panel renders sync info, progression (incl. 14-day
|
||||||
@@ -82,7 +90,12 @@ impl Plugin for ProfilePlugin {
|
|||||||
.add_message::<MouseWheel>()
|
.add_message::<MouseWheel>()
|
||||||
// Open/close/tab handling moved to `you_hub_plugin`
|
// Open/close/tab handling moved to `you_hub_plugin`
|
||||||
// (Phase E) — this plugin now owns body content + scroll.
|
// (Phase E) — this plugin now owns body content + scroll.
|
||||||
.add_systems(Update, scroll_profile_panel);
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
scroll_profile_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,21 @@ pub struct LevelUpEvent {
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct ProgressUpdate;
|
pub struct ProgressUpdate;
|
||||||
|
|
||||||
|
/// Set for `challenge_plugin::advance_on_challenge_win`. See the
|
||||||
|
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ChallengeCompletion;
|
||||||
|
|
||||||
|
/// Set for `daily_challenge_plugin::handle_daily_completion`. See the
|
||||||
|
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DailyCompletion;
|
||||||
|
|
||||||
|
/// Set for `weekly_goals_plugin::evaluate_weekly_goals`. See the
|
||||||
|
/// mode-completion chain configured in `ProgressPlugin::build` (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct WeeklyGoalsEval;
|
||||||
|
|
||||||
/// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`,
|
/// Bevy plugin that awards XP on `GameWonEvent`, persists `PlayerProgress`,
|
||||||
/// and emits `LevelUpEvent` whenever a win crosses a level boundary.
|
/// and emits `LevelUpEvent` whenever a win crosses a level boundary.
|
||||||
///
|
///
|
||||||
@@ -74,7 +89,27 @@ impl Plugin for ProgressPlugin {
|
|||||||
.add_message::<GameWonEvent>()
|
.add_message::<GameWonEvent>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
award_xp_on_win.after(GameMutation).in_set(ProgressUpdate),
|
award_xp_on_win
|
||||||
|
.after(GameMutation)
|
||||||
|
.in_set(ProgressUpdate)
|
||||||
|
// Base win XP lands before the mode bonuses so the
|
||||||
|
// level-up attribution is stable.
|
||||||
|
.before(ChallengeCompletion),
|
||||||
|
)
|
||||||
|
// Mode-completion evaluators, pinned in one explicit chain
|
||||||
|
// INSIDE ProgressUpdate — every `.after(ProgressUpdate)`
|
||||||
|
// reader then sees fully-settled progress. Each evaluator
|
||||||
|
// drains GameWonEvent via its own cursor and writes disjoint
|
||||||
|
// ProgressResource fields plus XP, but a daily win also counts
|
||||||
|
// toward weekly goals, so two members can fire on the same
|
||||||
|
// frame — we pin an order rather than argue the XP/level-up
|
||||||
|
// event stream is commutative (#143).
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
(ChallengeCompletion, DailyCompletion, WeeklyGoalsEval)
|
||||||
|
.chain()
|
||||||
|
.in_set(ProgressUpdate)
|
||||||
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -185,16 +185,31 @@ impl Plugin for RadialMenuPlugin {
|
|||||||
// Tests inject `RadialCursorOverride` themselves; production
|
// Tests inject `RadialCursorOverride` themselves; production
|
||||||
// never touches it. We do not `init_resource` here so the
|
// never touches it. We do not `init_resource` here so the
|
||||||
// cursor-from-window path is the default.
|
// cursor-from-window path is the default.
|
||||||
|
// The radial gesture reacts to pointer state the drag pipeline
|
||||||
|
// settles, and its release handler feeds the shared move
|
||||||
|
// request stream consumed at GameMutation this frame, so the
|
||||||
|
// chain sits between PointerInput and GameMutation. The redraw
|
||||||
|
// reads settings (reduce-motion) where a frame of lag is
|
||||||
|
// invisible — SettingsAccess club (#143).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
radial_open_on_right_click,
|
radial_open_on_right_click,
|
||||||
radial_open_on_long_press,
|
radial_open_on_long_press,
|
||||||
radial_track_cursor,
|
radial_track_cursor,
|
||||||
radial_handle_release_or_cancel,
|
radial_handle_release_or_cancel
|
||||||
radial_redraw_overlay,
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
|
||||||
|
radial_redraw_overlay
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.after(crate::input_plugin::PointerInput)
|
||||||
|
// Selection keys also touch DragState (mutual pointer/
|
||||||
|
// keyboard exclusion) — keys settle first.
|
||||||
|
.after(crate::selection_plugin::SelectionKeySet)
|
||||||
|
.before(crate::game_plugin::GameMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -464,25 +464,71 @@ impl Plugin for ReplayOverlayPlugin {
|
|||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
react_to_state_change,
|
react_to_state_change,
|
||||||
update_banner_label,
|
// Marker-filtered Text/Node repaints — each owns one
|
||||||
update_progress_text,
|
// disjoint overlay marker, so they join the UiTextFx
|
||||||
update_floating_progress_chip,
|
// club alongside the HUD text painters (#143).
|
||||||
update_scrub_fill,
|
update_banner_label
|
||||||
update_move_log_header,
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
update_move_log_active_row,
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
update_move_log_prev_rows,
|
update_progress_text
|
||||||
update_move_log_next_rows,
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
update_mini_tableau_foundations,
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
update_mini_tableau_stock_waste,
|
update_floating_progress_chip
|
||||||
update_pause_button_label,
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_scrub_fill
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_move_log_header
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_move_log_active_row
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_move_log_prev_rows
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_move_log_next_rows
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_mini_tableau_foundations
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_mini_tableau_stock_waste
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
update_pause_button_label
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_step_button,
|
// Step/scrub dispatch into the shared move/draw/undo
|
||||||
|
// request streams (drained wholesale at GameMutation),
|
||||||
|
// so they join the writer clubs (#143).
|
||||||
|
handle_step_button
|
||||||
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
handle_pause_keyboard,
|
handle_pause_keyboard,
|
||||||
handle_stop_keyboard,
|
handle_stop_keyboard,
|
||||||
handle_arrow_keyboard,
|
handle_arrow_keyboard
|
||||||
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
handle_stop_button,
|
handle_stop_button,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
// After the playback driver so painters render the
|
||||||
|
// post-tick state; before GameMutation so the step /
|
||||||
|
// scrub events are consumed this frame. Click handlers
|
||||||
|
// writing ReplayPlaybackState stay at the end of the
|
||||||
|
// chain — a press is observed by react_to_state_change
|
||||||
|
// next frame (state-driven loop, see above) (#143).
|
||||||
|
.after(crate::replay_playback::ReplayPlayback)
|
||||||
|
.before(crate::game_plugin::GameMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -550,6 +550,14 @@ fn fire_state_changed_on_completion(
|
|||||||
*last_was_completed = now_completed;
|
*last_was_completed = now_completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set wrapping the replay playback driver chain (tick → auto-clear →
|
||||||
|
/// completion `StateChangedEvent`). The tick feeds `MoveRequestEvent` /
|
||||||
|
/// `DrawRequestEvent` into this frame's [`GameMutation`], so the set sits
|
||||||
|
/// on the pre-mutation spine; the overlay UI (`replay_overlay`) orders
|
||||||
|
/// itself after this set so its painters render the post-tick state (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct ReplayPlayback;
|
||||||
|
|
||||||
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
|
/// Bevy plugin that initialises [`ReplayPlaybackState`] and drives
|
||||||
/// playback ticks, completion linger, and the recording-pause guard.
|
/// playback ticks, completion linger, and the recording-pause guard.
|
||||||
///
|
///
|
||||||
@@ -562,18 +570,46 @@ impl Plugin for ReplayPlaybackPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<ReplayPlaybackState>()
|
app.init_resource::<ReplayPlaybackState>()
|
||||||
.add_message::<StateChangedEvent>()
|
.add_message::<StateChangedEvent>()
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
ReplayPlayback
|
||||||
|
.after(crate::game_plugin::PreMutation)
|
||||||
|
// Input first: a stop/pause press or a drag-emitted
|
||||||
|
// request this frame is observed before the tick fires
|
||||||
|
// the next scripted move (#143).
|
||||||
|
.after(crate::input_plugin::PointerInput)
|
||||||
|
.before(GameMutation),
|
||||||
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
tick_replay_playback,
|
// The tick shares the move/draw request streams with the
|
||||||
|
// pointer/keyboard writers (drained wholesale at
|
||||||
|
// GameMutation) and reads playback speed from settings.
|
||||||
|
tick_replay_playback
|
||||||
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
auto_clear_completed_replay,
|
auto_clear_completed_replay,
|
||||||
|
// Chained before GameMutation: explicit order against the
|
||||||
|
// mutation systems' StateChangedEvent writes; readers
|
||||||
|
// drain the whole queue after GameMutation.
|
||||||
fire_state_changed_on_completion,
|
fire_state_changed_on_completion,
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.in_set(ReplayPlayback),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
record_replay_skip_during_playback.after(GameMutation),
|
record_replay_skip_during_playback
|
||||||
|
.after(GameMutation)
|
||||||
|
// After AutoComplete so the stats overlay's watch-replay
|
||||||
|
// handler (which resets ReplayPlaybackState) and the
|
||||||
|
// win-path recorder have both settled (#143).
|
||||||
|
.after(crate::auto_complete_plugin::AutoComplete),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,6 +102,25 @@ pub enum SyncStatus {
|
|||||||
#[derive(Resource, Debug, Clone, Default)]
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
pub struct SyncStatusResource(pub SyncStatus);
|
pub struct SyncStatusResource(pub SyncStatus);
|
||||||
|
|
||||||
|
/// Outcome of the most recent successful sync merge (Phase M — sync
|
||||||
|
/// transparency). `None` until the first pull of the session resolves.
|
||||||
|
///
|
||||||
|
/// The Account tab renders the conflict details; the sync poller emits a
|
||||||
|
/// one-line toast summary when `conflicts` is non-empty.
|
||||||
|
#[derive(Resource, Debug, Clone, Default)]
|
||||||
|
pub struct SyncConflictLog(pub Option<SyncMergeSummary>);
|
||||||
|
|
||||||
|
/// Timestamp + per-field conflict list captured from
|
||||||
|
/// [`solitaire_sync::merge`] after a pull resolves.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SyncMergeSummary {
|
||||||
|
/// When the merge resolved.
|
||||||
|
pub at: DateTime<Utc>,
|
||||||
|
/// Fields that could not be merged deterministically (best-effort
|
||||||
|
/// resolution already applied — see `solitaire_sync::merge`).
|
||||||
|
pub conflicts: Vec<solitaire_sync::ConflictReport>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Tracks which hint the player is currently cycling through.
|
/// Tracks which hint the player is currently cycling through.
|
||||||
///
|
///
|
||||||
/// Incremented on each H press so repeated presses reveal different moves.
|
/// Incremented on each H press so repeated presses reveal different moves.
|
||||||
|
|||||||
@@ -73,11 +73,29 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
.init_resource::<SafeAreaInsets>()
|
.init_resource::<SafeAreaInsets>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
|
(
|
||||||
|
// All three write Node padding on disjoint chrome roots
|
||||||
|
// (top anchors / bottom anchors / modal scrims); chained
|
||||||
|
// for a deterministic pass, and they join the UiTextFx
|
||||||
|
// chrome club — anchored roots are never the label/
|
||||||
|
// tooltip/readout entities the other members touch,
|
||||||
|
// which Bevy can't prove from the queries (#143).
|
||||||
(
|
(
|
||||||
apply_safe_area_anchors,
|
apply_safe_area_anchors,
|
||||||
apply_safe_area_bottom_anchors,
|
apply_safe_area_bottom_anchors,
|
||||||
apply_safe_area_to_modal_scrims,
|
apply_safe_area_to_modal_scrims,
|
||||||
on_app_resumed,
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
// UiScale is written on the settings mutator spine.
|
||||||
|
.after(crate::settings_plugin::SettingsMutation),
|
||||||
|
// Emits a synthetic WindowResized on resume only; every
|
||||||
|
// reader drains the queue via cursor, and a resume
|
||||||
|
// already spans hundreds of ms, so whether the synthetic
|
||||||
|
// event lands this frame or next is invisible — same
|
||||||
|
// argument as handle_fullscreen (#143).
|
||||||
|
on_app_resumed.ambiguous_with_all(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,43 @@ mod tests {
|
|||||||
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
|
use bevy::ecs::schedule::{LogLevel, ScheduleBuildSettings};
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::achievement_plugin::AchievementPlugin;
|
||||||
|
use crate::animation_plugin::AnimationPlugin;
|
||||||
use crate::auto_complete_plugin::AutoCompletePlugin;
|
use crate::auto_complete_plugin::AutoCompletePlugin;
|
||||||
|
use crate::card_animation::CardAnimationPlugin;
|
||||||
use crate::card_plugin::CardPlugin;
|
use crate::card_plugin::CardPlugin;
|
||||||
|
use crate::challenge_plugin::ChallengePlugin;
|
||||||
|
use crate::cursor_plugin::CursorPlugin;
|
||||||
|
use crate::daily_challenge_plugin::DailyChallengePlugin;
|
||||||
|
use crate::difficulty_plugin::DifficultyPlugin;
|
||||||
|
use crate::feedback_anim_plugin::FeedbackAnimPlugin;
|
||||||
use crate::game_plugin::GamePlugin;
|
use crate::game_plugin::GamePlugin;
|
||||||
|
use crate::help_plugin::HelpPlugin;
|
||||||
|
use crate::home_plugin::HomePlugin;
|
||||||
use crate::hud_plugin::HudPlugin;
|
use crate::hud_plugin::HudPlugin;
|
||||||
|
use crate::input_plugin::InputPlugin;
|
||||||
|
use crate::leaderboard_plugin::LeaderboardPlugin;
|
||||||
|
use crate::onboarding_plugin::OnboardingPlugin;
|
||||||
|
use crate::pause_plugin::PausePlugin;
|
||||||
|
use crate::play_by_seed_plugin::PlayBySeedPlugin;
|
||||||
|
use crate::profile_plugin::ProfilePlugin;
|
||||||
|
use crate::progress_plugin::ProgressPlugin;
|
||||||
|
use crate::radial_menu::RadialMenuPlugin;
|
||||||
|
use crate::replay_overlay::ReplayOverlayPlugin;
|
||||||
|
use crate::replay_playback::ReplayPlaybackPlugin;
|
||||||
|
use crate::safe_area::SafeAreaInsetsPlugin;
|
||||||
|
use crate::selection_plugin::SelectionPlugin;
|
||||||
use crate::settings_plugin::SettingsPlugin;
|
use crate::settings_plugin::SettingsPlugin;
|
||||||
|
use crate::splash_plugin::SplashPlugin;
|
||||||
|
use crate::stats_plugin::StatsPlugin;
|
||||||
use crate::table_plugin::TablePlugin;
|
use crate::table_plugin::TablePlugin;
|
||||||
|
use crate::time_attack_plugin::TimeAttackPlugin;
|
||||||
|
use crate::touch_selection_plugin::TouchSelectionPlugin;
|
||||||
use crate::ui_focus::UiFocusPlugin;
|
use crate::ui_focus::UiFocusPlugin;
|
||||||
use crate::ui_modal::UiModalPlugin;
|
use crate::ui_modal::UiModalPlugin;
|
||||||
|
use crate::ui_tooltip::UiTooltipPlugin;
|
||||||
|
use crate::weekly_goals_plugin::WeeklyGoalsPlugin;
|
||||||
|
use crate::win_summary_plugin::WinSummaryPlugin;
|
||||||
|
|
||||||
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
|
/// The backlog (302 pairs on 2026-07-06) was burned down to ZERO the
|
||||||
/// same day (#143, PRs #146–#149) — this is now a hard gate. If your
|
/// same day (#143, PRs #146–#149) — this is now a hard gate. If your
|
||||||
@@ -51,7 +80,40 @@ mod tests {
|
|||||||
.add_plugins(AutoCompletePlugin)
|
.add_plugins(AutoCompletePlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.add_plugins(UiFocusPlugin)
|
.add_plugins(UiFocusPlugin)
|
||||||
.add_plugins(SettingsPlugin::headless());
|
.add_plugins(SettingsPlugin::headless())
|
||||||
|
// Batch: game-adjacent plugins (all already exercised headless
|
||||||
|
// by the engine behaviour tests).
|
||||||
|
.add_plugins(ProgressPlugin::headless())
|
||||||
|
.add_plugins(StatsPlugin::headless())
|
||||||
|
.add_plugins(AchievementPlugin::headless())
|
||||||
|
.add_plugins(SelectionPlugin)
|
||||||
|
.add_plugins(AnimationPlugin)
|
||||||
|
.add_plugins(CardAnimationPlugin)
|
||||||
|
.add_plugins(FeedbackAnimPlugin)
|
||||||
|
.add_plugins(PausePlugin)
|
||||||
|
// Batch: screen/overlay plugins.
|
||||||
|
.add_plugins(HomePlugin::headless())
|
||||||
|
.add_plugins(WinSummaryPlugin)
|
||||||
|
.add_plugins(SplashPlugin)
|
||||||
|
.add_plugins(HelpPlugin)
|
||||||
|
.add_plugins(OnboardingPlugin)
|
||||||
|
.add_plugins(ProfilePlugin)
|
||||||
|
.add_plugins(LeaderboardPlugin)
|
||||||
|
// Batch: mode/replay/input plugins.
|
||||||
|
.add_plugins(ChallengePlugin)
|
||||||
|
.add_plugins(DailyChallengePlugin)
|
||||||
|
.add_plugins(TimeAttackPlugin)
|
||||||
|
.add_plugins(WeeklyGoalsPlugin)
|
||||||
|
.add_plugins(PlayBySeedPlugin)
|
||||||
|
.add_plugins(DifficultyPlugin)
|
||||||
|
.add_plugins(ReplayPlaybackPlugin)
|
||||||
|
.add_plugins(ReplayOverlayPlugin)
|
||||||
|
.add_plugins(RadialMenuPlugin)
|
||||||
|
.add_plugins(InputPlugin)
|
||||||
|
.add_plugins(TouchSelectionPlugin)
|
||||||
|
.add_plugins(UiTooltipPlugin)
|
||||||
|
.add_plugins(CursorPlugin)
|
||||||
|
.add_plugins(SafeAreaInsetsPlugin);
|
||||||
app.init_resource::<ButtonInput<KeyCode>>();
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -134,6 +134,14 @@ impl KeyboardDragState {
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct SelectionKeySet;
|
pub struct SelectionKeySet;
|
||||||
|
|
||||||
|
/// Set wrapping the post-mutation selection maintenance chain (clearing the
|
||||||
|
/// selection on state change, painting the highlight). Runs between
|
||||||
|
/// [`crate::game_plugin::GameMutation`] and
|
||||||
|
/// [`crate::card_plugin::BoardVisuals`] so the painters read the settled
|
||||||
|
/// selection/drag state (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SelectionFx;
|
||||||
|
|
||||||
/// Marker component placed on the outline sprite used as the keyboard-selection
|
/// Marker component placed on the outline sprite used as the keyboard-selection
|
||||||
/// highlight.
|
/// highlight.
|
||||||
///
|
///
|
||||||
@@ -158,14 +166,35 @@ impl Plugin for SelectionPlugin {
|
|||||||
(
|
(
|
||||||
handle_selection_keys
|
handle_selection_keys
|
||||||
.in_set(SelectionKeySet)
|
.in_set(SelectionKeySet)
|
||||||
.before(GameMutation),
|
.before(GameMutation)
|
||||||
clear_selection_on_state_change.after(GameMutation),
|
// Enter-to-move feeds the shared move request
|
||||||
update_selection_highlight.after(GameMutation).run_if(
|
// stream (drained wholesale at GameMutation, #143).
|
||||||
|
.in_set(crate::game_plugin::MoveRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::MoveRequestWriters),
|
||||||
|
(
|
||||||
|
clear_selection_on_state_change,
|
||||||
|
update_selection_highlight.run_if(
|
||||||
resource_changed::<SelectionState>
|
resource_changed::<SelectionState>
|
||||||
.or(resource_changed::<KeyboardDragState>)
|
.or(resource_changed::<KeyboardDragState>)
|
||||||
.or(resource_changed::<GameStateResource>),
|
.or(resource_changed::<GameStateResource>),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.in_set(SelectionFx),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
SelectionFx
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
|
)
|
||||||
|
.configure_sets(
|
||||||
|
Update,
|
||||||
|
// Selection reads GameStateResource (legal destinations) —
|
||||||
|
// run after the pre-mutation spine that ticks/mutates it
|
||||||
|
// (#143).
|
||||||
|
SelectionKeySet.after(crate::game_plugin::PreMutation),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
//! Phase M (sync transparency): local backup export/import.
|
||||||
|
//!
|
||||||
|
//! Runs in the `Last` schedule — the export snapshot is taken after the
|
||||||
|
//! whole frame settles, and the import's resource rewrites become visible
|
||||||
|
//! to every Update system on the next frame, keeping this rare bulk
|
||||||
|
//! operation off the annotated Update spine entirely.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
use solitaire_data::{DataBundle, data_bundle_path, load_bundle_from, save_bundle_to};
|
||||||
|
|
||||||
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
|
use crate::events::{
|
||||||
|
DataExportRequestEvent, DataImportRequestEvent, InfoToastEvent, WarningToastEvent,
|
||||||
|
};
|
||||||
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
|
use crate::stats_plugin::{StatsResource, StatsStoragePath};
|
||||||
|
|
||||||
|
use super::{SettingsChangedEvent, SettingsResource, SettingsStoragePath};
|
||||||
|
|
||||||
|
/// Drains the export/import request streams and performs the file IO.
|
||||||
|
///
|
||||||
|
/// The bundle is small (a few KB of JSON), so the write happens inline
|
||||||
|
/// rather than on the async pool — one synchronous write per explicit
|
||||||
|
/// button press, never per frame.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub(super) fn handle_data_transfer(
|
||||||
|
mut exports: MessageReader<DataExportRequestEvent>,
|
||||||
|
mut imports: MessageReader<DataImportRequestEvent>,
|
||||||
|
mut settings: ResMut<SettingsResource>,
|
||||||
|
settings_path: Res<SettingsStoragePath>,
|
||||||
|
stats: Option<ResMut<StatsResource>>,
|
||||||
|
stats_path: Option<Res<StatsStoragePath>>,
|
||||||
|
achievements: Option<ResMut<AchievementsResource>>,
|
||||||
|
achievements_path: Option<Res<AchievementsStoragePath>>,
|
||||||
|
progress: Option<ResMut<ProgressResource>>,
|
||||||
|
progress_path: Option<Res<ProgressStoragePath>>,
|
||||||
|
mut settings_changed: MessageWriter<SettingsChangedEvent>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
) {
|
||||||
|
let export_requested = exports.read().next().is_some();
|
||||||
|
let import_requested = imports.read().next().is_some();
|
||||||
|
if !export_requested && !import_requested {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// The bundle lives next to settings.json (same app data dir in
|
||||||
|
// production); deriving it from the injected settings path keeps
|
||||||
|
// tests off the real platform directory.
|
||||||
|
let path = settings_path
|
||||||
|
.0
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.with_file_name(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME))
|
||||||
|
.or_else(data_bundle_path);
|
||||||
|
let Some(path) = path else {
|
||||||
|
warning_toast.write(WarningToastEvent(
|
||||||
|
"No data directory available on this platform".to_string(),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
// An import in the same frame as an export would race on the file;
|
||||||
|
// real UI can't produce both, but be deterministic anyway: export
|
||||||
|
// first, then import reads what was just written (a no-op restore).
|
||||||
|
if export_requested {
|
||||||
|
let bundle = DataBundle {
|
||||||
|
schema_version: solitaire_data::DATA_BUNDLE_SCHEMA_VERSION,
|
||||||
|
exported_at: Utc::now(),
|
||||||
|
settings: settings.0.clone(),
|
||||||
|
stats: stats.as_ref().map(|s| s.0.clone()).unwrap_or_default(),
|
||||||
|
achievements: achievements
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.0.clone())
|
||||||
|
.unwrap_or_default(),
|
||||||
|
progress: progress.as_ref().map(|p| p.0.clone()).unwrap_or_default(),
|
||||||
|
};
|
||||||
|
match save_bundle_to(&path, &bundle) {
|
||||||
|
Ok(()) => {
|
||||||
|
info_toast.write(InfoToastEvent(format!(
|
||||||
|
"Data exported to {}",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("data export failed: {e}");
|
||||||
|
warning_toast.write(WarningToastEvent(format!("Export failed: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if import_requested {
|
||||||
|
match load_bundle_from(&path) {
|
||||||
|
Ok(bundle) => {
|
||||||
|
settings.0 = bundle.settings;
|
||||||
|
if let Some(target) = &settings_path.0
|
||||||
|
&& let Err(e) = solitaire_data::save_settings_to(target, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("import: failed to persist settings: {e}");
|
||||||
|
}
|
||||||
|
// Settings appliers (theme, audio, layout) react to the
|
||||||
|
// change event exactly as if edited in the panel.
|
||||||
|
settings_changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
|
||||||
|
if let Some(mut s) = stats {
|
||||||
|
s.0 = bundle.stats;
|
||||||
|
if let Some(target) = stats_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = solitaire_data::save_stats_to(target, &s.0)
|
||||||
|
{
|
||||||
|
warn!("import: failed to persist stats: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mut a) = achievements {
|
||||||
|
a.0 = bundle.achievements;
|
||||||
|
if let Some(target) = achievements_path.as_ref().and_then(|p| p.0.as_ref())
|
||||||
|
&& let Err(e) = solitaire_data::save_achievements_to(target, &a.0)
|
||||||
|
{
|
||||||
|
warn!("import: failed to persist achievements: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(mut p) = progress {
|
||||||
|
p.0 = bundle.progress;
|
||||||
|
if let Some(target) = progress_path.as_ref().and_then(|pp| pp.0.as_ref())
|
||||||
|
&& let Err(e) = solitaire_data::save_progress_to(target, &p.0)
|
||||||
|
{
|
||||||
|
warn!("import: failed to persist progress: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
info_toast.write(InfoToastEvent(format!(
|
||||||
|
"Backup from {} restored",
|
||||||
|
bundle.exported_at.format("%Y-%m-%d %H:%M UTC")
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("data import failed: {e}");
|
||||||
|
warning_toast.write(WarningToastEvent(format!("Import failed: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -353,6 +353,14 @@ pub(super) fn handle_settings_buttons(
|
|||||||
**t = on_off_label(settings.0.reduce_motion_mode);
|
**t = on_off_label(settings.0.reduce_motion_mode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
SettingsButton::ToggleReduceTransparency => {
|
||||||
|
settings.0.reduce_transparency_mode = !settings.0.reduce_transparency_mode;
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_reduce_transparency_text`; the
|
||||||
|
// glass chrome itself follows via `update_glass_surfaces`
|
||||||
|
// next frame.
|
||||||
|
}
|
||||||
SettingsButton::ToggleTouchInputMode => {
|
SettingsButton::ToggleTouchInputMode => {
|
||||||
use solitaire_data::settings::TouchInputMode;
|
use solitaire_data::settings::TouchInputMode;
|
||||||
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
settings.0.touch_input_mode = match settings.0.touch_input_mode {
|
||||||
@@ -425,6 +433,9 @@ pub(super) fn handle_settings_buttons(
|
|||||||
SettingsButton::OpenThemeStore => {
|
SettingsButton::OpenThemeStore => {
|
||||||
// Handled by `handle_sync_buttons`.
|
// Handled by `handle_sync_buttons`.
|
||||||
}
|
}
|
||||||
|
SettingsButton::ExportData | SettingsButton::ImportData => {
|
||||||
|
// Handled by `handle_sync_buttons`.
|
||||||
|
}
|
||||||
SettingsButton::Done => {
|
SettingsButton::Done => {
|
||||||
screen.0 = false;
|
screen.0 = false;
|
||||||
}
|
}
|
||||||
@@ -452,14 +463,18 @@ pub(super) fn handle_tab_buttons(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
/// Handles sync-related settings buttons: Sync Now, Connect, Disconnect,
|
||||||
/// and Delete Account. Split from `handle_settings_buttons` to stay within
|
/// Delete Account, and the Phase M Export/Import data pair. Split from
|
||||||
/// Bevy's 16-parameter system limit.
|
/// `handle_settings_buttons` to stay within Bevy's 16-parameter system
|
||||||
|
/// limit.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub(super) fn handle_sync_buttons(
|
pub(super) fn handle_sync_buttons(
|
||||||
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
interaction_query: Query<(&Interaction, &SettingsButton), Changed<Interaction>>,
|
||||||
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
mut manual_sync: MessageWriter<ManualSyncRequestEvent>,
|
||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
mut logout_sync: MessageWriter<SyncLogoutRequestEvent>,
|
||||||
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
mut delete_account: MessageWriter<DeleteAccountRequestEvent>,
|
||||||
|
mut export_data: MessageWriter<crate::events::DataExportRequestEvent>,
|
||||||
|
mut import_data: MessageWriter<crate::events::DataImportRequestEvent>,
|
||||||
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
|
#[cfg(not(target_arch = "wasm32"))] mut open_theme_store: MessageWriter<
|
||||||
crate::events::ThemeStoreOpenRequestEvent,
|
crate::events::ThemeStoreOpenRequestEvent,
|
||||||
>,
|
>,
|
||||||
@@ -492,6 +507,12 @@ pub(super) fn handle_sync_buttons(
|
|||||||
SettingsButton::DeleteAccount => {
|
SettingsButton::DeleteAccount => {
|
||||||
delete_account.write(DeleteAccountRequestEvent);
|
delete_account.write(DeleteAccountRequestEvent);
|
||||||
}
|
}
|
||||||
|
SettingsButton::ExportData => {
|
||||||
|
export_data.write(crate::events::DataExportRequestEvent);
|
||||||
|
}
|
||||||
|
SettingsButton::ImportData => {
|
||||||
|
import_data.write(crate::events::DataImportRequestEvent);
|
||||||
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ use crate::events::{
|
|||||||
use crate::resources::SettingsScrollPos;
|
use crate::resources::SettingsScrollPos;
|
||||||
use crate::theme::ThemeThumbnailPair;
|
use crate::theme::ThemeThumbnailPair;
|
||||||
|
|
||||||
|
mod data_transfer;
|
||||||
mod input;
|
mod input;
|
||||||
mod ui;
|
mod ui;
|
||||||
mod updates;
|
mod updates;
|
||||||
@@ -87,6 +88,19 @@ pub struct SettingsChangedEvent(pub Settings);
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct SettingsMutation;
|
pub struct SettingsMutation;
|
||||||
|
|
||||||
|
/// Self-ambiguous club for the screen/overlay systems that touch
|
||||||
|
/// [`SettingsResource`] (onboarding first-run flag, home difficulty/draw
|
||||||
|
/// chips, leaderboard opt-in and display name, pause draw toggle, splash
|
||||||
|
/// reduce-motion reads, …). Each accessor touches its own disjoint fields,
|
||||||
|
/// and the modal-stacking guard means at most one of these screens is
|
||||||
|
/// interactive at a time — same-frame access order is provably irrelevant,
|
||||||
|
/// so members declare `.in_set(SettingsAccess).ambiguous_with(SettingsAccess)`
|
||||||
|
/// (#143). [`SettingsMutation`] is nested inside the club (its members keep
|
||||||
|
/// their internal chain). Do NOT add members whose settings read must be
|
||||||
|
/// same-frame-exact; order those `.after(SettingsMutation)` instead.
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SettingsAccess;
|
||||||
|
|
||||||
/// Marker on the root Settings panel entity.
|
/// Marker on the root Settings panel entity.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct SettingsPanel;
|
pub struct SettingsPanel;
|
||||||
@@ -111,6 +125,12 @@ struct ThemeText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct SyncStatusText;
|
struct SyncStatusText;
|
||||||
|
|
||||||
|
/// Marks the headline `Text` node of the last-merge summary in the
|
||||||
|
/// Account tab (Phase M — sync transparency). Tests query it to assert
|
||||||
|
/// the summary rendered.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub(super) struct SyncMergeSummaryText;
|
||||||
|
|
||||||
/// Marks the `Text` node showing the active card-back index.
|
/// Marks the `Text` node showing the active card-back index.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct CardBackText;
|
struct CardBackText;
|
||||||
@@ -135,6 +155,10 @@ struct HighContrastText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct ReduceMotionText;
|
struct ReduceMotionText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current reduce-transparency state.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct ReduceTransparencyText;
|
||||||
|
|
||||||
/// Marks the `Text` node showing the current touch input mode state.
|
/// Marks the `Text` node showing the current touch input mode state.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TouchInputModeText;
|
struct TouchInputModeText;
|
||||||
@@ -277,6 +301,10 @@ enum SettingsButton {
|
|||||||
/// non-essential motion (card-slide animations become instant
|
/// non-essential motion (card-slide animations become instant
|
||||||
/// snaps) per `design-system.md` §Accessibility (#3).
|
/// snaps) per `design-system.md` §Accessibility (#3).
|
||||||
ToggleReduceMotion,
|
ToggleReduceMotion,
|
||||||
|
/// Toggle the [`Settings::reduce_transparency_mode`] flag — swaps
|
||||||
|
/// translucent glass chrome (the floating touch tab bar) for opaque
|
||||||
|
/// fills so table content never bleeds through UI text.
|
||||||
|
ToggleReduceTransparency,
|
||||||
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
|
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
|
||||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||||
/// a card/stack, second tap on a target pile moves it).
|
/// a card/stack, second tap on a target pile moves it).
|
||||||
@@ -313,6 +341,10 @@ enum SettingsButton {
|
|||||||
DisconnectSync,
|
DisconnectSync,
|
||||||
/// Open the account-deletion confirmation modal.
|
/// Open the account-deletion confirmation modal.
|
||||||
DeleteAccount,
|
DeleteAccount,
|
||||||
|
/// Write every JSON save into one backup bundle (Phase M).
|
||||||
|
ExportData,
|
||||||
|
/// Restore every JSON save from the backup bundle (Phase M).
|
||||||
|
ImportData,
|
||||||
Done,
|
Done,
|
||||||
/// Select a specific card-back by index from the picker row.
|
/// Select a specific card-back by index from the picker row.
|
||||||
SelectCardBack(usize),
|
SelectCardBack(usize),
|
||||||
@@ -361,8 +393,9 @@ impl SettingsButton {
|
|||||||
// run before continuing to the picker rows.
|
// run before continuing to the picker rows.
|
||||||
SettingsButton::ToggleHighContrast => 61,
|
SettingsButton::ToggleHighContrast => 61,
|
||||||
SettingsButton::ToggleReduceMotion => 62,
|
SettingsButton::ToggleReduceMotion => 62,
|
||||||
SettingsButton::ToggleTouchInputMode => 63,
|
SettingsButton::ToggleReduceTransparency => 63,
|
||||||
SettingsButton::CycleUiScale => 64,
|
SettingsButton::ToggleTouchInputMode => 64,
|
||||||
|
SettingsButton::CycleUiScale => 65,
|
||||||
// Picker rows — every swatch in a row shares the row's
|
// Picker rows — every swatch in a row shares the row's
|
||||||
// priority so entity-index tiebreaking yields left → right.
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
SettingsButton::SelectCardBack(_) => 70,
|
SettingsButton::SelectCardBack(_) => 70,
|
||||||
@@ -377,6 +410,8 @@ impl SettingsButton {
|
|||||||
SettingsButton::ConnectSync => 91,
|
SettingsButton::ConnectSync => 91,
|
||||||
SettingsButton::DisconnectSync => 92,
|
SettingsButton::DisconnectSync => 92,
|
||||||
SettingsButton::DeleteAccount => 93,
|
SettingsButton::DeleteAccount => 93,
|
||||||
|
SettingsButton::ExportData => 94,
|
||||||
|
SettingsButton::ImportData => 95,
|
||||||
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
// Done is tagged by `attach_focusable_to_modal_buttons` and
|
||||||
// never reaches `attach_focusable_to_settings_buttons`; the
|
// never reaches `attach_focusable_to_settings_buttons`; the
|
||||||
// value here is only a fallback for completeness.
|
// value here is only a fallback for completeness.
|
||||||
@@ -430,6 +465,15 @@ impl Plugin for SettingsPlugin {
|
|||||||
.add_message::<SyncConfigureRequestEvent>()
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
.add_message::<SyncLogoutRequestEvent>()
|
.add_message::<SyncLogoutRequestEvent>()
|
||||||
.add_message::<DeleteAccountRequestEvent>()
|
.add_message::<DeleteAccountRequestEvent>()
|
||||||
|
.add_message::<crate::events::DataExportRequestEvent>()
|
||||||
|
.add_message::<crate::events::DataImportRequestEvent>()
|
||||||
|
// Idempotent — the sync plugin also registers it; needed here
|
||||||
|
// so the transfer handler's failure toasts work headless.
|
||||||
|
.add_message::<crate::events::WarningToastEvent>()
|
||||||
|
// Bulk backup/restore lives in Last: the export snapshot is
|
||||||
|
// taken post-frame and an import's rewrites surface next
|
||||||
|
// frame — keeps the rare path off the annotated Update spine.
|
||||||
|
.add_systems(Last, data_transfer::handle_data_transfer)
|
||||||
.add_message::<ToggleSettingsRequestEvent>()
|
.add_message::<ToggleSettingsRequestEvent>()
|
||||||
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
|
.add_message::<crate::events::ThemeStoreOpenRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
@@ -450,7 +494,12 @@ impl Plugin for SettingsPlugin {
|
|||||||
Update,
|
Update,
|
||||||
SettingsMutation
|
SettingsMutation
|
||||||
.after(crate::layout::LayoutSystem::UpdateOnResize)
|
.after(crate::layout::LayoutSystem::UpdateOnResize)
|
||||||
.before(crate::game_plugin::GameMutation),
|
.before(crate::game_plugin::GameMutation)
|
||||||
|
// Nested in the club: members keep their chain, but
|
||||||
|
// their pairs against other screen-plugin settings
|
||||||
|
// accessors are declared irrelevant (#143).
|
||||||
|
.in_set(SettingsAccess)
|
||||||
|
.ambiguous_with(SettingsAccess),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -473,8 +522,12 @@ impl Plugin for SettingsPlugin {
|
|||||||
toggle_settings_screen
|
toggle_settings_screen
|
||||||
.before(crate::ui_focus::FocusKeys)
|
.before(crate::ui_focus::FocusKeys)
|
||||||
.ambiguous_with(crate::hud_plugin::HudButtons),
|
.ambiguous_with(crate::hud_plugin::HudButtons),
|
||||||
scroll_settings_panel,
|
scroll_settings_panel
|
||||||
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>,
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
crate::ui_modal::touch_scroll_panel::<SettingsPanelScrollable>
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
);
|
);
|
||||||
@@ -514,6 +567,16 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_smart_default_size_text,
|
update_smart_default_size_text,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
app.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
update_reduce_transparency_text,
|
||||||
|
// Guards internally on settings-change / newly added
|
||||||
|
// glass — `Added<GlassSurface>` can't be expressed as
|
||||||
|
// a resource run condition.
|
||||||
|
update_glass_surfaces,
|
||||||
|
),
|
||||||
|
);
|
||||||
app.add_systems(
|
app.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -793,3 +793,93 @@ fn ui_scale_out_of_range_sanitizes_on_load() {
|
|||||||
.sanitized();
|
.sanitized();
|
||||||
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
|
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase M: local data export / import
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// App with a real (temp-dir) settings path so the data-transfer handler
|
||||||
|
/// derives its bundle path inside the temp dir instead of the platform
|
||||||
|
/// data directory.
|
||||||
|
fn transfer_app(dir: &std::path::Path) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(SettingsPlugin {
|
||||||
|
storage_path: Some(dir.join("settings.json")),
|
||||||
|
ui_enabled: false,
|
||||||
|
});
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>();
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn export_then_import_round_trips_settings() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("settings_transfer_{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
|
let mut app = transfer_app(&dir);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 0.25;
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(crate::events::DataExportRequestEvent);
|
||||||
|
app.update();
|
||||||
|
assert!(
|
||||||
|
dir.join(solitaire_data::transfer::DATA_BUNDLE_FILE_NAME)
|
||||||
|
.exists(),
|
||||||
|
"export should write the bundle next to settings.json"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clobber the live value, then restore from the bundle.
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 1.0;
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(crate::events::DataImportRequestEvent);
|
||||||
|
app.update();
|
||||||
|
// Import handling runs in Last of the same frame; the rewrite is
|
||||||
|
// visible immediately after update() returns.
|
||||||
|
let restored = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!(
|
||||||
|
(restored - 0.25).abs() < f32::EPSILON,
|
||||||
|
"import should restore the exported value, got {restored}"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn import_without_backup_warns_instead_of_defaulting() {
|
||||||
|
let dir =
|
||||||
|
std::env::temp_dir().join(format!("settings_transfer_missing_{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||||
|
let mut app = transfer_app(&dir);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.sfx_volume = 0.25;
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(crate::events::DataImportRequestEvent);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
// Settings untouched (no silent default-restore)…
|
||||||
|
let v = app.world().resource::<SettingsResource>().0.sfx_volume;
|
||||||
|
assert!(
|
||||||
|
(v - 0.25).abs() < f32::EPSILON,
|
||||||
|
"failed import must not touch settings"
|
||||||
|
);
|
||||||
|
// …and the failure is surfaced as a warning toast.
|
||||||
|
let msgs = app
|
||||||
|
.world()
|
||||||
|
.resource::<Messages<crate::events::WarningToastEvent>>();
|
||||||
|
let mut cursor = msgs.get_cursor();
|
||||||
|
assert!(
|
||||||
|
cursor.read(msgs).any(|w| w.0.contains("Import failed")),
|
||||||
|
"missing backup should emit a warning toast"
|
||||||
|
);
|
||||||
|
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub(super) fn spawn_settings_panel(
|
|||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
theme_overrides_back: bool,
|
theme_overrides_back: bool,
|
||||||
|
conflict_log: Option<&crate::resources::SyncConflictLog>,
|
||||||
active_tab: SettingsTab,
|
active_tab: SettingsTab,
|
||||||
) {
|
) {
|
||||||
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
spawn_modal(commands, SettingsPanel, Z_MODAL_PANEL, |card| {
|
||||||
@@ -100,7 +101,9 @@ pub(super) fn spawn_settings_panel(
|
|||||||
font_res,
|
font_res,
|
||||||
),
|
),
|
||||||
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
SettingsTab::Accessibility => spawn_accessibility_tab(body, settings, font_res),
|
||||||
SettingsTab::Account => spawn_account_tab(body, settings, sync_status, font_res),
|
SettingsTab::Account => {
|
||||||
|
spawn_account_tab(body, settings, sync_status, conflict_log, font_res)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Done is the only action — primary so the player always knows
|
// Done is the only action — primary so the player always knows
|
||||||
@@ -317,6 +320,15 @@ fn spawn_accessibility_tab(
|
|||||||
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"Reduce Transparency",
|
||||||
|
ReduceTransparencyText,
|
||||||
|
on_off_label(settings.reduce_transparency_mode),
|
||||||
|
SettingsButton::ToggleReduceTransparency,
|
||||||
|
"Replaces the see-through glass toolbar with a solid panel so cards behind it never reduce readability.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
toggle_row(
|
toggle_row(
|
||||||
body,
|
body,
|
||||||
"Touch Input Mode",
|
"Touch Input Mode",
|
||||||
@@ -344,6 +356,7 @@ fn spawn_account_tab(
|
|||||||
body: &mut ChildSpawnerCommands,
|
body: &mut ChildSpawnerCommands,
|
||||||
settings: &Settings,
|
settings: &Settings,
|
||||||
sync_status: &str,
|
sync_status: &str,
|
||||||
|
conflict_log: Option<&crate::resources::SyncConflictLog>,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
// --- Privacy (only shown when a Matomo URL is configured) ---
|
// --- Privacy (only shown when a Matomo URL is configured) ---
|
||||||
@@ -363,6 +376,144 @@ fn spawn_account_tab(
|
|||||||
// --- Sync ---
|
// --- Sync ---
|
||||||
section_label(body, "Sync", font_res);
|
section_label(body, "Sync", font_res);
|
||||||
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
sync_row(body, sync_status, &settings.sync_backend, font_res);
|
||||||
|
spawn_sync_merge_summary(body, conflict_log, font_res);
|
||||||
|
|
||||||
|
// --- Local data (Phase M) --- backup/restore of the JSON saves;
|
||||||
|
// filesystem-backed, so not offered on the wasm build.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
{
|
||||||
|
section_label(body, "Local data", font_res);
|
||||||
|
local_data_row(body, font_res);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `Export data` / `Import data` pill buttons plus a caption naming the
|
||||||
|
/// backup file (Phase M). Mirrors the sync row's pill-button styling.
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
fn local_data_row(parent: &mut ChildSpawnerCommands, font_res: Option<&FontResource>) {
|
||||||
|
let button_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let caption_font = button_font.clone();
|
||||||
|
|
||||||
|
let small_button = |row: &mut ChildSpawnerCommands,
|
||||||
|
marker: SettingsButton,
|
||||||
|
label: &str,
|
||||||
|
tooltip: String,
|
||||||
|
font: TextFont| {
|
||||||
|
row.spawn((
|
||||||
|
marker,
|
||||||
|
Button,
|
||||||
|
Tooltip::new(tooltip),
|
||||||
|
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(BG_ELEVATED_HI),
|
||||||
|
BorderColor::all(BORDER_SUBTLE),
|
||||||
|
HighContrastBorder::with_default(BORDER_SUBTLE),
|
||||||
|
))
|
||||||
|
.with_children(|b| {
|
||||||
|
b.spawn((Text::new(label.to_string()), font, TextColor(TEXT_PRIMARY)));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
parent
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|col| {
|
||||||
|
col.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
flex_wrap: FlexWrap::Wrap,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|row| {
|
||||||
|
small_button(
|
||||||
|
row,
|
||||||
|
SettingsButton::ExportData,
|
||||||
|
"Export data",
|
||||||
|
"Write settings, stats, achievements and progress to one backup file"
|
||||||
|
.to_string(),
|
||||||
|
button_font.clone(),
|
||||||
|
);
|
||||||
|
small_button(
|
||||||
|
row,
|
||||||
|
SettingsButton::ImportData,
|
||||||
|
"Import data",
|
||||||
|
"Restore all saves from the backup file (replaces current data)".to_string(),
|
||||||
|
button_font.clone(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
col.spawn((
|
||||||
|
Text::new(format!(
|
||||||
|
"Backup file: {}",
|
||||||
|
solitaire_data::data_bundle_path()
|
||||||
|
.map_or_else(|| "unavailable".to_string(), |p| p.display().to_string())
|
||||||
|
)),
|
||||||
|
caption_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Phase M (sync transparency): renders what the most recent sync merge
|
||||||
|
/// did — a clean-merge caption, or one line per conflicting field with
|
||||||
|
/// both values, so the player can see exactly which numbers were
|
||||||
|
/// reconciled and how.
|
||||||
|
fn spawn_sync_merge_summary(
|
||||||
|
body: &mut ChildSpawnerCommands,
|
||||||
|
conflict_log: Option<&crate::resources::SyncConflictLog>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let Some(summary) = conflict_log.and_then(|log| log.0.as_ref()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let caption_font = TextFont {
|
||||||
|
font: font_res.map(|f| f.0.clone()).unwrap_or_default(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let when = summary.at.format("%H:%M UTC");
|
||||||
|
if summary.conflicts.is_empty() {
|
||||||
|
body.spawn((
|
||||||
|
SyncMergeSummaryText,
|
||||||
|
Text::new(format!("Last merge at {when}: no conflicts")),
|
||||||
|
caption_font,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let n = summary.conflicts.len();
|
||||||
|
let plural = if n == 1 { "conflict" } else { "conflicts" };
|
||||||
|
body.spawn((
|
||||||
|
SyncMergeSummaryText,
|
||||||
|
Text::new(format!(
|
||||||
|
"Last merge at {when}: {n} {plural} — kept newer values"
|
||||||
|
)),
|
||||||
|
caption_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
for c in &summary.conflicts {
|
||||||
|
body.spawn((
|
||||||
|
Text::new(format!(
|
||||||
|
" {} — this device: {} / server: {}",
|
||||||
|
c.field, c.local_value, c.remote_value
|
||||||
|
)),
|
||||||
|
caption_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Section divider — small lavender label inside the scrollable body.
|
/// Section divider — small lavender label inside the scrollable body.
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ use crate::font_plugin::FontResource;
|
|||||||
use crate::progress_plugin::ProgressResource;
|
use crate::progress_plugin::ProgressResource;
|
||||||
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
use crate::resources::{SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
use crate::theme::ThemeThumbnailCache;
|
use crate::theme::ThemeThumbnailCache;
|
||||||
|
use crate::ui_glass::{GlassSurface, glass_decorations};
|
||||||
use crate::ui_modal::ModalScrim;
|
use crate::ui_modal::ModalScrim;
|
||||||
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
use crate::ui_theme::{BORDER_SUBTLE_HC, HighContrastBackground, HighContrastBorder};
|
||||||
|
use bevy::ui::{BackgroundGradient, BorderGradient};
|
||||||
|
|
||||||
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
/// Spawns the Settings panel when `SettingsScreen` becomes `true`;
|
||||||
/// despawns it when it becomes `false`.
|
/// despawns it when it becomes `false`.
|
||||||
@@ -30,6 +32,7 @@ pub(super) fn sync_settings_panel_visibility(
|
|||||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||||
|
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
|
||||||
) {
|
) {
|
||||||
if !screen.is_changed() {
|
if !screen.is_changed() {
|
||||||
return;
|
return;
|
||||||
@@ -45,6 +48,7 @@ pub(super) fn sync_settings_panel_visibility(
|
|||||||
theme_registry.as_deref(),
|
theme_registry.as_deref(),
|
||||||
theme_thumbs.as_deref(),
|
theme_thumbs.as_deref(),
|
||||||
card_images.as_deref(),
|
card_images.as_deref(),
|
||||||
|
conflict_log.as_deref(),
|
||||||
scroll_pos.0,
|
scroll_pos.0,
|
||||||
active_tab.0,
|
active_tab.0,
|
||||||
);
|
);
|
||||||
@@ -74,6 +78,7 @@ fn build_panel(
|
|||||||
theme_registry: Option<&crate::theme::ThemeRegistry>,
|
theme_registry: Option<&crate::theme::ThemeRegistry>,
|
||||||
theme_thumbs: Option<&ThemeThumbnailCache>,
|
theme_thumbs: Option<&ThemeThumbnailCache>,
|
||||||
card_images: Option<&crate::card_plugin::CardImageSet>,
|
card_images: Option<&crate::card_plugin::CardImageSet>,
|
||||||
|
conflict_log: Option<&crate::resources::SyncConflictLog>,
|
||||||
scroll_offset: f32,
|
scroll_offset: f32,
|
||||||
active_tab: SettingsTab,
|
active_tab: SettingsTab,
|
||||||
) {
|
) {
|
||||||
@@ -123,6 +128,7 @@ fn build_panel(
|
|||||||
scroll_offset,
|
scroll_offset,
|
||||||
font_res,
|
font_res,
|
||||||
theme_overrides_back,
|
theme_overrides_back,
|
||||||
|
conflict_log,
|
||||||
active_tab,
|
active_tab,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,6 +149,7 @@ pub(super) fn rebuild_panel_on_tab_change(
|
|||||||
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
theme_registry: Option<Res<crate::theme::ThemeRegistry>>,
|
||||||
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
theme_thumbs: Option<Res<ThemeThumbnailCache>>,
|
||||||
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
card_images: Option<Res<crate::card_plugin::CardImageSet>>,
|
||||||
|
conflict_log: Option<Res<crate::resources::SyncConflictLog>>,
|
||||||
) {
|
) {
|
||||||
if !active_tab.is_changed() || active_tab.is_added() {
|
if !active_tab.is_changed() || active_tab.is_added() {
|
||||||
return;
|
return;
|
||||||
@@ -165,6 +172,7 @@ pub(super) fn rebuild_panel_on_tab_change(
|
|||||||
theme_registry.as_deref(),
|
theme_registry.as_deref(),
|
||||||
theme_thumbs.as_deref(),
|
theme_thumbs.as_deref(),
|
||||||
card_images.as_deref(),
|
card_images.as_deref(),
|
||||||
|
conflict_log.as_deref(),
|
||||||
0.0,
|
0.0,
|
||||||
active_tab.0,
|
active_tab.0,
|
||||||
);
|
);
|
||||||
@@ -330,6 +338,41 @@ pub(super) fn update_reduce_motion_text(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(super) fn update_reduce_transparency_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<ReduceTransparencyText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = on_off_label(settings.0.reduce_transparency_mode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retargets every [`GlassSurface`]'s sheen + rim gradients when the
|
||||||
|
/// reduce-transparency or high-contrast toggles change. Runs on
|
||||||
|
/// `SettingsResource` change and on newly added surfaces (so glass
|
||||||
|
/// spawned after startup picks up an already-active toggle). Parallel to
|
||||||
|
/// [`update_high_contrast_borders`]: same trigger, different components.
|
||||||
|
pub(super) fn update_glass_surfaces(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut surfaces: Query<(&mut BackgroundGradient, &mut BorderGradient), With<GlassSurface>>,
|
||||||
|
added: Query<(), Added<GlassSurface>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() && added.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (sheen, rim) = glass_decorations(
|
||||||
|
settings.0.reduce_transparency_mode,
|
||||||
|
settings.0.high_contrast_mode,
|
||||||
|
);
|
||||||
|
for (mut s, mut r) in &mut surfaces {
|
||||||
|
*s = sheen.clone();
|
||||||
|
*r = rim.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn update_touch_input_mode_text(
|
pub(super) fn update_touch_input_mode_text(
|
||||||
settings: Res<SettingsResource>,
|
settings: Res<SettingsResource>,
|
||||||
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
|
||||||
|
|||||||
@@ -99,7 +99,18 @@ impl Plugin for SplashPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.add_systems(Startup, spawn_splash).add_systems(
|
app.add_systems(Startup, spawn_splash).add_systems(
|
||||||
Update,
|
Update,
|
||||||
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor).chain(),
|
// Splash text/fade writers touch UI entities only — they join
|
||||||
|
// the UiTextFx club (disjoint from board/anim sets) and, for
|
||||||
|
// the reduce-motion read in the pulse, the screen-plugin
|
||||||
|
// settings club. Disjoint from the HUD button painters for the
|
||||||
|
// same entity-domain reason (#143).
|
||||||
|
(dismiss_splash_on_input, advance_splash, pulse_splash_cursor)
|
||||||
|
.chain()
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::hud_plugin::HudButtons)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use crate::ui_modal::{ButtonVariant, ModalButton, spawn_modal_button};
|
|||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
ACCENT_PRIMARY, BG_ELEVATED_HI, BORDER_SUBTLE, HighContrastBorder, RADIUS_SM, STATE_INFO,
|
||||||
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
STATE_WARNING, STREAK_MILESTONES, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG,
|
||||||
TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
TYPE_HEADLINE, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Bevy resource wrapping the current stats.
|
/// Bevy resource wrapping the current stats.
|
||||||
@@ -46,6 +46,14 @@ pub struct StatsStoragePath(pub Option<PathBuf>);
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct StatsUpdate;
|
pub struct StatsUpdate;
|
||||||
|
|
||||||
|
/// Set holding the abandon-recorder (`update_stats_on_new_game`). It must
|
||||||
|
/// read the outgoing game's `move_count` before any mode-start handler or
|
||||||
|
/// `GameMutation` clobbers the game state — mode plugins order their reset
|
||||||
|
/// handlers `.after(AbandonRecord)` via `crate::game_plugin::ModeStart`
|
||||||
|
/// (#143).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct AbandonRecord;
|
||||||
|
|
||||||
/// Marker component on the stats overlay root node.
|
/// Marker component on the stats overlay root node.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct StatsScreen;
|
pub struct StatsScreen;
|
||||||
@@ -205,10 +213,34 @@ impl Plugin for StatsPlugin {
|
|||||||
// constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)),
|
// constraints (win_summary_plugin: cache_win_data.before(StatsUpdate)),
|
||||||
// and a system cannot be both inside a set and individually before a
|
// and a system cannot be both inside a set and individually before a
|
||||||
// set-level ordering constraint.
|
// set-level ordering constraint.
|
||||||
.add_systems(Update, update_stats_on_new_game.before(GameMutation))
|
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
update_stats_on_win.after(GameMutation).in_set(StatsUpdate),
|
update_stats_on_new_game
|
||||||
|
.in_set(AbandonRecord)
|
||||||
|
.before(GameMutation)
|
||||||
|
// Reads the shared new-game request stream via its own
|
||||||
|
// cursor — append order is irrelevant, so it joins the
|
||||||
|
// self-ambiguous writer club (NewGameRequestWriters must
|
||||||
|
// not carry set-level edges; see its doc). The explicit
|
||||||
|
// edges pin it after the pause/settings/selection
|
||||||
|
// mutators and the pre-mutation game-state spine it
|
||||||
|
// reads `move_count` from (#143).
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.after(crate::game_plugin::PreMutation)
|
||||||
|
.after(crate::pause_plugin::PauseMutation)
|
||||||
|
.after(crate::settings_plugin::SettingsMutation)
|
||||||
|
.after(crate::selection_plugin::SelectionKeySet),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
update_stats_on_win
|
||||||
|
.after(GameMutation)
|
||||||
|
.in_set(StatsUpdate)
|
||||||
|
// Also emits toasts (streak milestones) — join the
|
||||||
|
// self-ambiguous writer club (#143).
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -216,29 +248,69 @@ impl Plugin for StatsPlugin {
|
|||||||
// the NewGameRequestEvent it emits is not visible to
|
// the NewGameRequestEvent it emits is not visible to
|
||||||
// update_stats_on_new_game in the same frame — otherwise
|
// update_stats_on_new_game in the same frame — otherwise
|
||||||
// record_abandoned() fires twice on every forfeit (#21).
|
// record_abandoned() fires twice on every forfeit (#21).
|
||||||
|
// It drains the ForfeitEvent the pause/forfeit modal chain
|
||||||
|
// emits, and shares the new-game request stream with the
|
||||||
|
// other writers (#143).
|
||||||
handle_forfeit
|
handle_forfeit
|
||||||
.before(GameMutation)
|
.before(GameMutation)
|
||||||
.before(update_stats_on_new_game),
|
.before(update_stats_on_new_game)
|
||||||
|
.after(crate::pause_plugin::PauseMutation)
|
||||||
|
.after(crate::selection_plugin::SelectionKeySet)
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
)
|
)
|
||||||
// Open/close/tab handling moved to `you_hub_plugin`
|
// Open/close/tab handling moved to `you_hub_plugin`
|
||||||
// (Phase E) — this plugin now owns body content, replay
|
// (Phase E) — this plugin now owns body content, replay
|
||||||
// selector behavior, and scroll.
|
// selector behavior, and scroll.
|
||||||
.add_systems(Update, refresh_replay_history_on_win.after(GameMutation))
|
//
|
||||||
.add_systems(Update, handle_watch_replay_button)
|
// Stats overlay UI: one deterministic chain — every member
|
||||||
.add_systems(Update, handle_copy_share_link_button)
|
// touches the replay-selector state or the overlay's nodes, and
|
||||||
|
// handle_watch_replay_button resets the live game, so the chain
|
||||||
|
// sits between GameMutation and the auto-complete driver on the
|
||||||
|
// game-state timeline (#143). The toast-emitting members join
|
||||||
|
// the self-ambiguous InfoToastWriters set so the toast pipeline
|
||||||
|
// (AnimFx) drains them this frame.
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
|
refresh_replay_history_on_win,
|
||||||
handle_replay_selector_buttons,
|
handle_replay_selector_buttons,
|
||||||
repaint_replay_selector_caption,
|
// Marker-filtered Text repaints — disjoint from the
|
||||||
repaint_replay_selector_detail,
|
// HUD text painters (UiTextFx club, #143).
|
||||||
|
repaint_replay_selector_caption
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
repaint_replay_selector_detail
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
handle_watch_replay_button
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
handle_copy_share_link_button
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain()
|
||||||
|
.after(GameMutation)
|
||||||
|
// The overlay repaints read this frame's settled stats,
|
||||||
|
// progress, and achievement evaluation (level line,
|
||||||
|
// streak).
|
||||||
|
.after(StatsUpdate)
|
||||||
|
.after(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.after(crate::achievement_plugin::AchievementEval)
|
||||||
|
.before(crate::auto_complete_plugin::AutoComplete),
|
||||||
)
|
)
|
||||||
.add_systems(Update, scroll_stats_panel)
|
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
crate::ui_modal::touch_scroll_panel::<StatsScrollable>,
|
scroll_stats_panel
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
|
)
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
crate::ui_modal::touch_scroll_panel::<StatsScrollable>
|
||||||
|
.in_set(crate::ui_modal::PanelScroll)
|
||||||
|
.ambiguous_with(crate::ui_modal::PanelScroll),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -719,25 +791,17 @@ pub(crate) fn spawn_stats_body(
|
|||||||
},
|
},
|
||||||
))
|
))
|
||||||
.with_children(|body| {
|
.with_children(|body| {
|
||||||
// First-launch caption — sits above the grid as gentle nudge so
|
// First-launch empty state (Phase L) — sits above the grid so
|
||||||
// the wall of em-dashes reads as "nothing to track yet" rather
|
// the wall of em-dashes reads as "nothing to track yet" rather
|
||||||
// than as broken state.
|
// than as broken state.
|
||||||
if is_first_launch {
|
if is_first_launch {
|
||||||
body.spawn((
|
crate::ui_modal::spawn_empty_state(
|
||||||
Text::new("Play a game to start tracking stats."),
|
body,
|
||||||
TextFont {
|
"\u{2663}",
|
||||||
font_size: TYPE_CAPTION,
|
"Play a game to start tracking stats.",
|
||||||
..default()
|
None,
|
||||||
},
|
font_res,
|
||||||
TextColor(TEXT_SECONDARY),
|
);
|
||||||
Node {
|
|
||||||
margin: UiRect {
|
|
||||||
bottom: VAL_SPACE_2,
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
..default()
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- primary stat cells grid ---
|
// --- primary stat cells grid ---
|
||||||
@@ -893,6 +957,20 @@ pub(crate) fn spawn_replays_body(
|
|||||||
selected_index: usize,
|
selected_index: usize,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
|
// Standard empty state (Phase L) — the selector / Watch / Copy
|
||||||
|
// controls only spawn when there is something to select, so their
|
||||||
|
// handlers all no-op via their existing empty-query paths.
|
||||||
|
if replays.is_empty() {
|
||||||
|
crate::ui_modal::spawn_empty_state(
|
||||||
|
card,
|
||||||
|
"\u{2666}",
|
||||||
|
"No replays yet.",
|
||||||
|
Some("Every win records a replay here automatically."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
let font_row = TextFont {
|
let font_row = TextFont {
|
||||||
font: font_handle,
|
font: font_handle,
|
||||||
|
|||||||
@@ -26,11 +26,15 @@ use solitaire_sync::{SyncPayload, merge};
|
|||||||
|
|
||||||
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
use crate::achievement_plugin::{AchievementsResource, AchievementsStoragePath};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
GameWonEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent, WarningToastEvent,
|
GameWonEvent, InfoToastEvent, ManualSyncRequestEvent, SyncConfigureRequestEvent,
|
||||||
|
WarningToastEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::RecordingReplay;
|
use crate::game_plugin::RecordingReplay;
|
||||||
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
use crate::progress_plugin::{ProgressResource, ProgressStoragePath};
|
||||||
use crate::resources::{GameStateResource, SyncStatus, SyncStatusResource, TokioRuntimeResource};
|
use crate::resources::{
|
||||||
|
GameStateResource, SyncConflictLog, SyncMergeSummary, SyncStatus, SyncStatusResource,
|
||||||
|
TokioRuntimeResource,
|
||||||
|
};
|
||||||
use crate::stats_plugin::{
|
use crate::stats_plugin::{
|
||||||
LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath,
|
LatestReplayPath, ReplayHistoryResource, StatsResource, StatsStoragePath,
|
||||||
};
|
};
|
||||||
@@ -106,9 +110,13 @@ impl Plugin for SyncPlugin {
|
|||||||
.init_resource::<PullTaskResult>()
|
.init_resource::<PullTaskResult>()
|
||||||
.init_resource::<PullTask>()
|
.init_resource::<PullTask>()
|
||||||
.init_resource::<PendingReplayUpload>()
|
.init_resource::<PendingReplayUpload>()
|
||||||
|
.init_resource::<SyncConflictLog>()
|
||||||
.add_message::<ManualSyncRequestEvent>()
|
.add_message::<ManualSyncRequestEvent>()
|
||||||
.add_message::<SyncConfigureRequestEvent>()
|
.add_message::<SyncConfigureRequestEvent>()
|
||||||
.add_message::<WarningToastEvent>();
|
.add_message::<WarningToastEvent>()
|
||||||
|
// Idempotent — GamePlugin also registers it; re-register so the
|
||||||
|
// conflict-summary toast works under MinimalPlugins tests.
|
||||||
|
.add_message::<InfoToastEvent>();
|
||||||
|
|
||||||
// Build the shared Tokio runtime; disable all network sync if the OS
|
// Build the shared Tokio runtime; disable all network sync if the OS
|
||||||
// refuses to create threads (resource-limited environments, sandboxed
|
// refuses to create threads (resource-limited environments, sandboxed
|
||||||
@@ -198,6 +206,8 @@ fn poll_pull_result(
|
|||||||
progress_path: Res<ProgressStoragePath>,
|
progress_path: Res<ProgressStoragePath>,
|
||||||
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
mut configure_sync: MessageWriter<SyncConfigureRequestEvent>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
mut conflict_log: ResMut<SyncConflictLog>,
|
||||||
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
) {
|
) {
|
||||||
let Some(task) = task_res.0.as_mut() else {
|
let Some(task) = task_res.0.as_mut() else {
|
||||||
return;
|
return;
|
||||||
@@ -210,7 +220,21 @@ fn poll_pull_result(
|
|||||||
match result {
|
match result {
|
||||||
Ok(remote) => {
|
Ok(remote) => {
|
||||||
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
let local = build_payload(&stats.0, &achievements.0, &progress.0);
|
||||||
let (merged, _conflicts) = merge(&local, &remote);
|
let (merged, conflicts) = merge(&local, &remote);
|
||||||
|
|
||||||
|
// Phase M (sync transparency): keep the conflict details for
|
||||||
|
// the Account tab and summarise non-clean merges in a toast.
|
||||||
|
if !conflicts.is_empty() {
|
||||||
|
let n = conflicts.len();
|
||||||
|
let plural = if n == 1 { "conflict" } else { "conflicts" };
|
||||||
|
info_toast.write(InfoToastEvent(format!(
|
||||||
|
"Synced — {n} {plural}, kept newer values"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
conflict_log.0 = Some(SyncMergeSummary {
|
||||||
|
at: Utc::now(),
|
||||||
|
conflicts,
|
||||||
|
});
|
||||||
|
|
||||||
// Persist merged state atomically.
|
// Persist merged state atomically.
|
||||||
if let Some(p) = &stats_path.0
|
if let Some(p) = &stats_path.0
|
||||||
|
|||||||
@@ -112,7 +112,12 @@ impl Plugin for TablePlugin {
|
|||||||
// paint pipeline — markers and cards share Sprite/
|
// paint pipeline — markers and cards share Sprite/
|
||||||
// Transform access (#143).
|
// Transform access (#143).
|
||||||
(
|
(
|
||||||
apply_theme_on_settings_change,
|
// Drains SettingsChangedEvent via cursor — a home
|
||||||
|
// deal-option edit (post-mutation SettingsAccess
|
||||||
|
// writer) surfacing one frame later repaints one
|
||||||
|
// frame later, which is invisible (#143).
|
||||||
|
apply_theme_on_settings_change
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
apply_hint_pile_highlight,
|
apply_hint_pile_highlight,
|
||||||
tick_hint_pile_highlights,
|
tick_hint_pile_highlights,
|
||||||
sync_pile_marker_visibility.after(GameMutation),
|
sync_pile_marker_visibility.after(GameMutation),
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ use crate::resources::TokioRuntimeResource;
|
|||||||
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
use crate::settings_plugin::{SettingsPanel, SettingsResource};
|
||||||
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
use crate::theme::{ImportError, ThemeRegistry, import_theme, refresh_registry};
|
||||||
use crate::ui_modal::{
|
use crate::ui_modal::{
|
||||||
ButtonVariant, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
|
ButtonVariant, ModalScrim, ScrimDismissible, spawn_empty_state, spawn_modal,
|
||||||
spawn_modal_button, spawn_modal_header,
|
spawn_modal_actions, spawn_modal_button, spawn_modal_header,
|
||||||
};
|
};
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
BORDER_SUBTLE, TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_2, VAL_SPACE_3,
|
||||||
@@ -528,25 +528,31 @@ fn spawn_store_modal(
|
|||||||
|
|
||||||
match catalog_state {
|
match catalog_state {
|
||||||
CatalogState::Idle | CatalogState::Loading => {
|
CatalogState::Idle | CatalogState::Loading => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new("Loading catalog…"),
|
card,
|
||||||
body_font.clone(),
|
"\u{21BB}",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Loading the catalog\u{2026}",
|
||||||
));
|
None,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Error(msg) => {
|
CatalogState::Error(msg) => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new(format!("Could not load the catalog: {msg}")),
|
card,
|
||||||
body_font.clone(),
|
"!",
|
||||||
TextColor(TEXT_SECONDARY),
|
"Couldn't load the catalog.",
|
||||||
));
|
Some(msg.as_str()),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Loaded(entries) if entries.is_empty() => {
|
CatalogState::Loaded(entries) if entries.is_empty() => {
|
||||||
card.spawn((
|
spawn_empty_state(
|
||||||
Text::new("The server has no themes yet."),
|
card,
|
||||||
body_font.clone(),
|
"#",
|
||||||
TextColor(TEXT_SECONDARY),
|
"The server has no themes yet.",
|
||||||
));
|
Some("Themes dropped into the server's theme_store folder appear here."),
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
CatalogState::Loaded(entries) => {
|
CatalogState::Loaded(entries) => {
|
||||||
for entry in entries {
|
for entry in entries {
|
||||||
|
|||||||
@@ -109,13 +109,41 @@ impl Plugin for TimeAttackPlugin {
|
|||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<StartTimeAttackRequestEvent>()
|
.add_message::<StartTimeAttackRequestEvent>()
|
||||||
.add_message::<InfoToastEvent>()
|
.add_message::<InfoToastEvent>()
|
||||||
|
// ModeStart places it between the stats abandon-recorder and
|
||||||
|
// GameMutation; it shares the new-game request/toast streams
|
||||||
|
// with the other writers (#143).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
handle_start_time_attack_request.before(GameMutation),
|
handle_start_time_attack_request
|
||||||
|
.in_set(crate::game_plugin::ModeStart)
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
// One post-mutation chain for the TimeAttackResource writers:
|
||||||
|
// clock tick, win auto-deal, then the debounced disk save.
|
||||||
|
// The start handler (ModeStart, pre-mutation) is ordered
|
||||||
|
// against all three transitively. auto_deal's new-game request
|
||||||
|
// is drained wholesale next frame, so it joins the writer club
|
||||||
|
// (#143). PausedResource is settled: PauseMutation < GameMutation.
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
advance_time_attack,
|
||||||
|
auto_deal_on_time_attack_win
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
|
auto_save_time_attack_session,
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation)
|
||||||
|
// The HUD clock (after AutoComplete) and the ended-toast
|
||||||
|
// handler (AnimFx) read what this chain writes — settle
|
||||||
|
// it before both (#143).
|
||||||
|
.before(crate::auto_complete_plugin::AutoComplete)
|
||||||
|
.before(crate::animation_plugin::AnimFx),
|
||||||
)
|
)
|
||||||
.add_systems(Update, advance_time_attack)
|
|
||||||
.add_systems(Update, auto_deal_on_time_attack_win.after(GameMutation))
|
|
||||||
.add_systems(Update, auto_save_time_attack_session)
|
|
||||||
.add_systems(Last, save_time_attack_session_on_exit);
|
.add_systems(Last, save_time_attack_session_on_exit);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,7 +100,11 @@ impl Plugin for TouchSelectionPlugin {
|
|||||||
update_touch_selection_highlight,
|
update_touch_selection_highlight,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.after(GameMutation),
|
.after(GameMutation)
|
||||||
|
// Selection cleanup and highlight placement settle before
|
||||||
|
// the board painters repaint (and before resync's synthetic
|
||||||
|
// StateChanged / fan-frac's layout write land) (#143).
|
||||||
|
.before(crate::card_plugin::BoardVisuals),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,281 @@
|
|||||||
|
//! "Liquid glass" material for floating UI surfaces.
|
||||||
|
//!
|
||||||
|
//! Produces the translucent, light-catching treatment used by the floating
|
||||||
|
//! touch tab bar (`hud_plugin::tab_bar`): a semi-transparent fill with a
|
||||||
|
//! vertical sheen, a specular rim highlight around the border, and a soft
|
||||||
|
//! drop shadow underneath. The table shows through the fill *sharp* — Bevy's
|
||||||
|
//! UI pipeline has no backdrop-blur concept, so this module approximates
|
||||||
|
//! frosted glass with layered gradients instead of sampling what's behind
|
||||||
|
//! the node. That keeps it a plain `bevy_ui` bundle: no custom shaders, no
|
||||||
|
//! extra render passes, and identical behaviour on desktop, Android, and
|
||||||
|
//! WebGL2.
|
||||||
|
//!
|
||||||
|
//! # Swapping the treatment
|
||||||
|
//!
|
||||||
|
//! Layout code depends only on [`glass_surface`] and [`GLASS_BORDER_PX`].
|
||||||
|
//! A future real-refraction material (e.g. a `UiMaterial` sampling a blurred
|
||||||
|
//! render target) can replace this module's internals without touching any
|
||||||
|
//! caller — that boundary is the reason this file exists separately from
|
||||||
|
//! `hud_plugin/tab_bar.rs`.
|
||||||
|
//!
|
||||||
|
//! # Departure from the Terminal design system
|
||||||
|
//!
|
||||||
|
//! `ui_theme` documents HUD chrome as opaque "status-line" panels. The glass
|
||||||
|
//! bar is a deliberate, user-approved exception for the floating touch tab
|
||||||
|
//! bar only; do not reuse this material for modals or the top HUD band
|
||||||
|
//! without a matching design decision.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use bevy::ui::{
|
||||||
|
BackgroundGradient, BorderGradient, BoxShadow, ColorStop, LinearGradient, ShadowStyle,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::ui_theme::{BG_ELEVATED, BORDER_SUBTLE, BORDER_SUBTLE_HC};
|
||||||
|
|
||||||
|
/// Border width every glass surface must reserve in its `Node::border` so
|
||||||
|
/// the rim gradient has a strip to paint into.
|
||||||
|
pub const GLASS_BORDER_PX: f32 = 1.0;
|
||||||
|
|
||||||
|
/// Marker on every node that received [`glass_surface`]. The settings
|
||||||
|
/// plugin's `update_glass_surfaces` retargets these when the player
|
||||||
|
/// toggles reduce-transparency or high-contrast, swapping the gradients
|
||||||
|
/// in place via [`glass_decorations`].
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct GlassSurface;
|
||||||
|
|
||||||
|
/// Base fill at the *bottom* of the glass sheet — near-black at ~62%
|
||||||
|
/// opacity so the felt and cards remain visible through the bar while
|
||||||
|
/// keeping icon/label contrast comfortable.
|
||||||
|
const GLASS_FILL_BOTTOM: Color = Color::srgba(0.055, 0.055, 0.063, 0.62);
|
||||||
|
|
||||||
|
/// Fill at the *top* of the sheet — the same glass lifted towards white,
|
||||||
|
/// reading as the curved surface catching overhead light.
|
||||||
|
const GLASS_FILL_TOP: Color = Color::srgba(0.20, 0.21, 0.23, 0.66);
|
||||||
|
|
||||||
|
/// Rim highlight at the top edge — the bright specular line where a real
|
||||||
|
/// glass pill would catch the light source.
|
||||||
|
const GLASS_RIM_TOP: Color = Color::srgba(1.0, 1.0, 1.0, 0.38);
|
||||||
|
|
||||||
|
/// Rim at the midpoint — almost gone, so the highlight reads as a glint
|
||||||
|
/// rather than a drawn outline.
|
||||||
|
const GLASS_RIM_MID: Color = Color::srgba(1.0, 1.0, 1.0, 0.06);
|
||||||
|
|
||||||
|
/// Rim at the bottom edge — a faint secondary catch-light, as on the
|
||||||
|
/// underside of a curved surface above a bright table.
|
||||||
|
const GLASS_RIM_BOTTOM: Color = Color::srgba(1.0, 1.0, 1.0, 0.16);
|
||||||
|
|
||||||
|
/// Drop shadow under the floating surface.
|
||||||
|
const GLASS_SHADOW: Color = Color::srgba(0.0, 0.0, 0.0, 0.35);
|
||||||
|
|
||||||
|
/// High-contrast fill — same glass, far less see-through, so text and
|
||||||
|
/// icons keep contrast over any card art. Per `design-system.md`
|
||||||
|
/// §Accessibility the HC toggle trades aesthetics for legibility.
|
||||||
|
const GLASS_FILL_TOP_HC: Color = Color::srgba(0.20, 0.21, 0.23, 0.90);
|
||||||
|
/// High-contrast fill at the bottom of the sheet.
|
||||||
|
const GLASS_FILL_BOTTOM_HC: Color = Color::srgba(0.055, 0.055, 0.063, 0.88);
|
||||||
|
|
||||||
|
/// High-contrast rim at the top edge — matches the luminance of
|
||||||
|
/// `BORDER_SUBTLE_HC` so the bar's outline reads as strongly as every
|
||||||
|
/// other HC-boosted border.
|
||||||
|
const GLASS_RIM_TOP_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.75);
|
||||||
|
/// High-contrast rim at the midpoint.
|
||||||
|
const GLASS_RIM_MID_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.35);
|
||||||
|
/// High-contrast rim at the bottom edge.
|
||||||
|
const GLASS_RIM_BOTTOM_HC: Color = Color::srgba(1.0, 1.0, 1.0, 0.50);
|
||||||
|
|
||||||
|
/// Visual components for a floating glass surface.
|
||||||
|
///
|
||||||
|
/// The caller owns the `Node` and must set two fields for the material to
|
||||||
|
/// render correctly:
|
||||||
|
/// - `border: UiRect::all(Val::Px(GLASS_BORDER_PX))` — the rim gradient
|
||||||
|
/// paints into the border strip and is invisible without it.
|
||||||
|
/// - `border_radius` — half the node height for a fully-round pill.
|
||||||
|
///
|
||||||
|
/// Everything here is fragment-shader work inside Bevy's stock UI pipeline,
|
||||||
|
/// so it is safe on WebGL2 and adds no per-frame cost beyond ordinary nodes.
|
||||||
|
pub fn glass_surface() -> impl Bundle {
|
||||||
|
let (sheen, rim) = glass_decorations(false, false);
|
||||||
|
(
|
||||||
|
GlassSurface,
|
||||||
|
sheen,
|
||||||
|
rim,
|
||||||
|
// Soft shadow underneath sells the "floating above the table" read.
|
||||||
|
BoxShadow(vec![ShadowStyle {
|
||||||
|
color: GLASS_SHADOW,
|
||||||
|
x_offset: Val::Px(0.0),
|
||||||
|
y_offset: Val::Px(6.0),
|
||||||
|
spread_radius: Val::Px(0.0),
|
||||||
|
blur_radius: Val::Px(16.0),
|
||||||
|
}]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sheen + rim pair for the requested accessibility state. The
|
||||||
|
/// settings plugin overwrites every [`GlassSurface`]'s components with
|
||||||
|
/// these when the relevant toggles change.
|
||||||
|
///
|
||||||
|
/// - Default: translucent fill with a top-lit sheen and a specular rim.
|
||||||
|
/// - `high_contrast`: same shape, near-opaque fill, and a rim boosted to
|
||||||
|
/// `BORDER_SUBTLE_HC` luminance so the outline stays legible.
|
||||||
|
/// - `reduce_transparency`: flat opaque `BG_ELEVATED` fill and a flat
|
||||||
|
/// border — no see-through at all. Combined with `high_contrast` the
|
||||||
|
/// flat border brightens to `BORDER_SUBTLE_HC`.
|
||||||
|
pub fn glass_decorations(
|
||||||
|
reduce_transparency: bool,
|
||||||
|
high_contrast: bool,
|
||||||
|
) -> (BackgroundGradient, BorderGradient) {
|
||||||
|
if reduce_transparency {
|
||||||
|
// "Gradients" with a single stop render as flat fills — reusing the
|
||||||
|
// same component types means the settings toggle swaps values, not
|
||||||
|
// component sets.
|
||||||
|
let border = if high_contrast {
|
||||||
|
BORDER_SUBTLE_HC
|
||||||
|
} else {
|
||||||
|
BORDER_SUBTLE
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
BackgroundGradient(vec![
|
||||||
|
LinearGradient::new(
|
||||||
|
LinearGradient::TO_BOTTOM,
|
||||||
|
vec![ColorStop::new(BG_ELEVATED, Val::Percent(0.0))],
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
]),
|
||||||
|
BorderGradient(vec![
|
||||||
|
LinearGradient::new(
|
||||||
|
LinearGradient::TO_BOTTOM,
|
||||||
|
vec![ColorStop::new(border, Val::Percent(0.0))],
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let (fill_top, fill_bottom, rim_top, rim_mid, rim_bottom) = if high_contrast {
|
||||||
|
(
|
||||||
|
GLASS_FILL_TOP_HC,
|
||||||
|
GLASS_FILL_BOTTOM_HC,
|
||||||
|
GLASS_RIM_TOP_HC,
|
||||||
|
GLASS_RIM_MID_HC,
|
||||||
|
GLASS_RIM_BOTTOM_HC,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
GLASS_FILL_TOP,
|
||||||
|
GLASS_FILL_BOTTOM,
|
||||||
|
GLASS_RIM_TOP,
|
||||||
|
GLASS_RIM_MID,
|
||||||
|
GLASS_RIM_BOTTOM,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
(
|
||||||
|
// Sheen: one top-to-bottom linear gradient carries both the fill and
|
||||||
|
// the lighting so there is a single source of truth for the surface
|
||||||
|
// colour (a separate `BackgroundColor` would just be painted over).
|
||||||
|
BackgroundGradient(vec![
|
||||||
|
LinearGradient::new(
|
||||||
|
LinearGradient::TO_BOTTOM,
|
||||||
|
vec![
|
||||||
|
ColorStop::new(fill_top, Val::Percent(0.0)),
|
||||||
|
ColorStop::new(fill_bottom, Val::Percent(60.0)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
]),
|
||||||
|
// Specular rim: bright at the top edge, fading out through the
|
||||||
|
// sides, with a faint return at the bottom.
|
||||||
|
BorderGradient(vec![
|
||||||
|
LinearGradient::new(
|
||||||
|
LinearGradient::TO_BOTTOM,
|
||||||
|
vec![
|
||||||
|
ColorStop::new(rim_top, Val::Percent(0.0)),
|
||||||
|
ColorStop::new(rim_mid, Val::Percent(55.0)),
|
||||||
|
ColorStop::new(rim_bottom, Val::Percent(100.0)),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bevy::ui::Gradient;
|
||||||
|
|
||||||
|
/// The bundle must insert all three visual components — a regression
|
||||||
|
/// here (e.g. a refactor dropping the rim) would silently flatten the
|
||||||
|
/// glass into a plain translucent box.
|
||||||
|
#[test]
|
||||||
|
fn glass_surface_inserts_sheen_rim_and_shadow() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let e = world.spawn(glass_surface()).id();
|
||||||
|
assert!(world.get::<BackgroundGradient>(e).is_some());
|
||||||
|
assert!(world.get::<BorderGradient>(e).is_some());
|
||||||
|
assert!(world.get::<BoxShadow>(e).is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The fill must stay translucent (that is the whole point of glass) but
|
||||||
|
/// opaque enough that text keeps contrast over busy card art.
|
||||||
|
#[test]
|
||||||
|
fn glass_fill_alpha_stays_in_readable_band() {
|
||||||
|
for fill in [GLASS_FILL_TOP, GLASS_FILL_BOTTOM] {
|
||||||
|
let a = fill.alpha();
|
||||||
|
assert!(
|
||||||
|
(0.4..=0.85).contains(&a),
|
||||||
|
"glass fill alpha {a} outside readable 0.4–0.85 band"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The rim gradient must actually glint: strictly brightest at the top,
|
||||||
|
/// dimmest in the middle.
|
||||||
|
#[test]
|
||||||
|
fn rim_highlight_peaks_at_top() {
|
||||||
|
assert!(GLASS_RIM_TOP.alpha() > GLASS_RIM_BOTTOM.alpha());
|
||||||
|
assert!(GLASS_RIM_BOTTOM.alpha() > GLASS_RIM_MID.alpha());
|
||||||
|
assert!(GLASS_RIM_TOP_HC.alpha() > GLASS_RIM_BOTTOM_HC.alpha());
|
||||||
|
assert!(GLASS_RIM_BOTTOM_HC.alpha() > GLASS_RIM_MID_HC.alpha());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reduce-transparency must produce fully opaque fills — the entire
|
||||||
|
/// point of the toggle is that nothing shows through.
|
||||||
|
#[test]
|
||||||
|
fn reduce_transparency_is_fully_opaque() {
|
||||||
|
for high_contrast in [false, true] {
|
||||||
|
let (sheen, _) = glass_decorations(true, high_contrast);
|
||||||
|
for gradient in &sheen.0 {
|
||||||
|
let Gradient::Linear(linear) = gradient else {
|
||||||
|
panic!("reduce-transparency sheen must stay linear");
|
||||||
|
};
|
||||||
|
for stop in &linear.stops {
|
||||||
|
assert_eq!(
|
||||||
|
stop.color.alpha(),
|
||||||
|
1.0,
|
||||||
|
"opaque variant leaked translucency (hc={high_contrast})"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// High-contrast glass must be meaningfully less transparent than the
|
||||||
|
/// default, and its rim meaningfully brighter — otherwise the toggle
|
||||||
|
/// does nothing perceptible on this surface.
|
||||||
|
#[test]
|
||||||
|
fn high_contrast_boosts_fill_and_rim() {
|
||||||
|
assert!(GLASS_FILL_TOP_HC.alpha() >= GLASS_FILL_TOP.alpha() + 0.15);
|
||||||
|
assert!(GLASS_FILL_BOTTOM_HC.alpha() >= GLASS_FILL_BOTTOM.alpha() + 0.15);
|
||||||
|
assert!(GLASS_RIM_TOP_HC.alpha() >= GLASS_RIM_TOP.alpha() + 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `glass_surface()` must carry the marker the settings applier
|
||||||
|
/// retargets — without it the accessibility toggles silently skip
|
||||||
|
/// the bar.
|
||||||
|
#[test]
|
||||||
|
fn glass_surface_carries_retarget_marker() {
|
||||||
|
let mut world = World::new();
|
||||||
|
let e = world.spawn(glass_surface()).id();
|
||||||
|
assert!(world.get::<GlassSurface>(e).is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -60,9 +60,9 @@ use crate::settings_plugin::SettingsResource;
|
|||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
ACCENT_PRIMARY, ACCENT_PRIMARY_HOVER, ACCENT_SECONDARY, BG_BASE, BG_ELEVATED, BG_ELEVATED_HI,
|
||||||
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
BG_ELEVATED_PRESSED, BG_ELEVATED_TOP, BORDER_STRONG, BORDER_SUBTLE, HighContrastBorder,
|
||||||
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_PRIMARY,
|
MOTION_MODAL_SECS, RADIUS_LG, RADIUS_MD, RADIUS_SM, SCRIM, STATE_SUCCESS, TEXT_DISABLED,
|
||||||
TEXT_SECONDARY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3,
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_BODY_LG, TYPE_CAPTION, TYPE_DISPLAY,
|
||||||
VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
TYPE_HEADLINE, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4, VAL_SPACE_5, scaled_duration,
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -328,6 +328,80 @@ pub fn spawn_modal_body_text(
|
|||||||
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
|
parent.spawn((ModalBody, Text::new(text.into()), font, TextColor(color)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Standard empty / loading / error surface (Phase L): a large glyph,
|
||||||
|
/// a headline, and an optional secondary detail line, centred in the
|
||||||
|
/// available space. Every async or possibly-empty surface (leaderboard,
|
||||||
|
/// replays, theme store, first-launch stats) renders through this so
|
||||||
|
/// "nothing here yet" reads identically everywhere.
|
||||||
|
///
|
||||||
|
/// A next-step action is composition, not configuration: spawn a
|
||||||
|
/// [`spawn_modal_button`] (or any control) in the same parent right
|
||||||
|
/// after this call.
|
||||||
|
///
|
||||||
|
/// `glyph` MUST come from FiraMono-covered ranges — card suits
|
||||||
|
/// (U+2660–2666), Arrows (U+2190–21FF), or ASCII. The Geometric Shapes
|
||||||
|
/// block renders as tofu on Android (§10 / §11).
|
||||||
|
pub fn spawn_empty_state(
|
||||||
|
parent: &mut ChildSpawnerCommands,
|
||||||
|
glyph: &str,
|
||||||
|
headline: &str,
|
||||||
|
detail: Option<&str>,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_glyph = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_DISPLAY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_headline = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY_LG,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_detail = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
parent
|
||||||
|
.spawn((
|
||||||
|
EmptyStateBlock,
|
||||||
|
Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
padding: UiRect::axes(VAL_SPACE_3, VAL_SPACE_5),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.with_children(|block| {
|
||||||
|
block.spawn((
|
||||||
|
Text::new(glyph.to_string()),
|
||||||
|
font_glyph,
|
||||||
|
TextColor(TEXT_DISABLED),
|
||||||
|
));
|
||||||
|
block.spawn((
|
||||||
|
Text::new(headline.to_string()),
|
||||||
|
font_headline,
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
if let Some(detail) = detail {
|
||||||
|
block.spawn((
|
||||||
|
Text::new(detail.to_string()),
|
||||||
|
font_detail,
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marker on every [`spawn_empty_state`] block, mostly for tests.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct EmptyStateBlock;
|
||||||
|
|
||||||
/// Spawns the bottom actions row — flex-row with primary right-aligned.
|
/// Spawns the bottom actions row — flex-row with primary right-aligned.
|
||||||
/// The closure populates the row's buttons via `spawn_modal_button`.
|
/// The closure populates the row's buttons via `spawn_modal_button`.
|
||||||
///
|
///
|
||||||
@@ -472,6 +546,15 @@ pub fn spawn_tab_chip<M: Component>(
|
|||||||
// Generic touch-scroll helper
|
// Generic touch-scroll helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Self-ambiguous writer set for the per-modal scroll systems (mouse-wheel
|
||||||
|
/// and touch variants). Every member writes `ScrollPosition`, but each
|
||||||
|
/// targets only its own modal's scrollable body via a marker component, and
|
||||||
|
/// the modal-stacking guard means at most one such panel exists at a time —
|
||||||
|
/// the writes are disjoint by design, so members declare
|
||||||
|
/// `.in_set(PanelScroll).ambiguous_with(PanelScroll)` (#143).
|
||||||
|
#[derive(bevy::ecs::schedule::SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct PanelScroll;
|
||||||
|
|
||||||
/// Scrolls any `Overflow::scroll_y()` panel marked with `M` via single-finger
|
/// Scrolls any `Overflow::scroll_y()` panel marked with `M` via single-finger
|
||||||
/// touch pan. Add this as a system for each scrollable modal panel:
|
/// touch pan. Add this as a system for each scrollable modal panel:
|
||||||
///
|
///
|
||||||
@@ -766,7 +849,11 @@ impl Plugin for UiModalPlugin {
|
|||||||
.in_set(crate::ui_theme::UiTextFx)
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
.ambiguous_with(crate::ui_theme::UiTextFx)
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
.ambiguous_with(crate::hud_plugin::HudButtons),
|
.ambiguous_with(crate::hud_plugin::HudButtons)
|
||||||
|
// Reads only the anim-speed settings field — disjoint from
|
||||||
|
// the screen-plugin club's writes (#143).
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
);
|
);
|
||||||
// Click-outside-to-dismiss is independent of the open
|
// Click-outside-to-dismiss is independent of the open
|
||||||
// animation chain — it reads `just_pressed(Left)` and runs
|
// animation chain — it reads `just_pressed(Left)` and runs
|
||||||
@@ -1184,4 +1271,62 @@ mod tests {
|
|||||||
"exactly one of the two stacked dismissible modals should remain"
|
"exactly one of the two stacked dismissible modals should remain"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase L: standard empty state
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// The helper spawns glyph + headline (+ detail when given) under one
|
||||||
|
/// marked block, so every empty surface renders identically.
|
||||||
|
#[test]
|
||||||
|
fn empty_state_spawns_expected_structure() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
{
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
commands.spawn(Node::default()).with_children(|parent| {
|
||||||
|
spawn_empty_state(
|
||||||
|
parent,
|
||||||
|
"\u{2660}",
|
||||||
|
"Nothing here.",
|
||||||
|
Some("Do the thing."),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
spawn_empty_state(parent, "!", "No detail variant.", None, None);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let blocks = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<EmptyStateBlock>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(blocks, 2, "each call spawns exactly one marked block");
|
||||||
|
|
||||||
|
let texts: Vec<String> = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&Text>()
|
||||||
|
.iter(app.world())
|
||||||
|
.map(|t| t.0.clone())
|
||||||
|
.collect();
|
||||||
|
for expected in [
|
||||||
|
"\u{2660}",
|
||||||
|
"Nothing here.",
|
||||||
|
"Do the thing.",
|
||||||
|
"!",
|
||||||
|
"No detail variant.",
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
texts.iter().any(|t| t == expected),
|
||||||
|
"missing text node {expected:?}; got {texts:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
texts.len(),
|
||||||
|
5,
|
||||||
|
"the detail-less variant must not spawn a detail node"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,23 @@ impl Plugin for UiTooltipPlugin {
|
|||||||
fn build(&self, app: &mut App) {
|
fn build(&self, app: &mut App) {
|
||||||
app.init_resource::<TooltipState>()
|
app.init_resource::<TooltipState>()
|
||||||
.add_systems(Startup, spawn_tooltip_overlay)
|
.add_systems(Startup, spawn_tooltip_overlay)
|
||||||
.add_systems(Update, (track_tooltip_hover, show_or_hide_tooltip).chain());
|
// The tooltip pair only touches the tooltip overlay entity and
|
||||||
|
// reads settings for the enable flag — chrome-disjoint from the
|
||||||
|
// other UiTextFx members, and a settings read that may lag a
|
||||||
|
// frame joins the SettingsAccess club (#143).
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(track_tooltip_hover, show_or_hide_tooltip)
|
||||||
|
.chain()
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx)
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess)
|
||||||
|
// The tooltip node is neither a board entity nor a pile
|
||||||
|
// marker; the visibility writes can't collide (#143).
|
||||||
|
.ambiguous_with(crate::card_plugin::BoardVisuals)
|
||||||
|
.ambiguous_with(crate::table_plugin::MarkerVisuals),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ use solitaire_data::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use crate::events::{GameWonEvent, XpAwardedEvent};
|
use crate::events::{GameWonEvent, XpAwardedEvent};
|
||||||
use crate::game_plugin::GameMutation;
|
|
||||||
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath, ProgressUpdate};
|
use crate::progress_plugin::{LevelUpEvent, ProgressResource, ProgressStoragePath};
|
||||||
use crate::resources::GameStateResource;
|
use crate::resources::GameStateResource;
|
||||||
|
|
||||||
/// Fired when the player has just completed a weekly goal.
|
/// Fired when the player has just completed a weekly goal.
|
||||||
@@ -33,11 +33,11 @@ impl Plugin for WeeklyGoalsPlugin {
|
|||||||
.add_systems(Startup, roll_weekly_goals_on_startup)
|
.add_systems(Startup, roll_weekly_goals_on_startup)
|
||||||
// Run after GameMutation (so GameWonEvent is available) and
|
// Run after GameMutation (so GameWonEvent is available) and
|
||||||
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
|
// ProgressUpdate (so we don't fight ProgressPlugin's add_xp).
|
||||||
|
// Ordering comes from the mode-completion chain configured in
|
||||||
|
// progress_plugin (#143).
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
evaluate_weekly_goals
|
evaluate_weekly_goals.in_set(crate::progress_plugin::WeeklyGoalsEval),
|
||||||
.after(GameMutation)
|
|
||||||
.after(ProgressUpdate),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
//! shake duration elapses.
|
//! shake duration elapses.
|
||||||
|
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use solitaire_core::game_state::GameMode;
|
use solitaire_core::game_state::{GameMode, GameState};
|
||||||
use solitaire_core::scoring::compute_time_bonus;
|
use solitaire_core::scoring::compute_time_bonus;
|
||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
@@ -93,6 +93,40 @@ pub struct WinSummaryPending {
|
|||||||
/// score-breakdown reveal can format the mode-multiplier row
|
/// score-breakdown reveal can format the mode-multiplier row
|
||||||
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
/// (e.g. `Zen ×0.0`, `Classic ×1.0`).
|
||||||
pub mode: GameMode,
|
pub mode: GameMode,
|
||||||
|
/// Per-move-type recap of the winning game, e.g.
|
||||||
|
/// `"21 to foundation · 14 tableau moves · 9 flips · 1 recycle"`.
|
||||||
|
/// Built from the upstream session counters at win time; empty when
|
||||||
|
/// every counter is zero (synthesised test wins).
|
||||||
|
pub move_detail: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Formats the per-move-type recap line for the win modal from the
|
||||||
|
/// upstream session counters. Zero-valued components are omitted so the
|
||||||
|
/// line stays short; returns an empty string when nothing was counted
|
||||||
|
/// (only possible for synthesised test wins).
|
||||||
|
fn build_move_detail(game: &GameState) -> String {
|
||||||
|
let mut parts: Vec<String> = Vec::new();
|
||||||
|
let foundation = game.move_to_foundation_count();
|
||||||
|
if foundation > 0 {
|
||||||
|
parts.push(format!("{foundation} to foundation"));
|
||||||
|
}
|
||||||
|
let tableau = game.move_to_tableau_count();
|
||||||
|
if tableau > 0 {
|
||||||
|
parts.push(format!("{tableau} tableau moves"));
|
||||||
|
}
|
||||||
|
let flips = game.flip_up_count();
|
||||||
|
if flips > 0 {
|
||||||
|
parts.push(format!("{flips} flips"));
|
||||||
|
}
|
||||||
|
let recycles = game.recycle_count();
|
||||||
|
if recycles > 0 {
|
||||||
|
parts.push(format!("{recycles} recycles"));
|
||||||
|
}
|
||||||
|
let returns = game.move_from_foundation_count();
|
||||||
|
if returns > 0 {
|
||||||
|
parts.push(format!("{returns} foundation returns"));
|
||||||
|
}
|
||||||
|
parts.join(" \u{00B7} ")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Builds a human-readable XP breakdown string for the win modal.
|
/// Builds a human-readable XP breakdown string for the win modal.
|
||||||
@@ -232,20 +266,54 @@ impl Plugin for WinSummaryPlugin {
|
|||||||
// the player's old personal-best values before `StatsPlugin` overwrites them.
|
// the player's old personal-best values before `StatsPlugin` overwrites them.
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
cache_win_data.after(GameMutation).before(StatsUpdate),
|
cache_win_data
|
||||||
|
.after(GameMutation)
|
||||||
|
.before(StatsUpdate)
|
||||||
|
// Must also read pre-award progress (old level for the
|
||||||
|
// level-up row) and emits the personal-best toast.
|
||||||
|
.before(crate::progress_plugin::ProgressUpdate)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
)
|
)
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
|
// One deterministic chain, late on the frame: after AnimFx
|
||||||
|
// so the unlock/XP event streams it drains are settled by
|
||||||
|
// both achievement evaluators (win + replay-completion run
|
||||||
|
// before AnimFx). Members join the request/toast writer
|
||||||
|
// clubs they append to; the camera shake and the score
|
||||||
|
// reveal touch entity domains disjoint from the card/HUD
|
||||||
|
// painters (#143).
|
||||||
(
|
(
|
||||||
collect_session_achievements,
|
collect_session_achievements
|
||||||
spawn_win_summary_after_delay,
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
handle_win_summary_buttons,
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters),
|
||||||
|
spawn_win_summary_after_delay
|
||||||
|
.in_set(crate::settings_plugin::SettingsAccess)
|
||||||
|
.ambiguous_with(crate::settings_plugin::SettingsAccess),
|
||||||
|
handle_win_summary_buttons
|
||||||
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
close_overlay_on_watch_replay,
|
close_overlay_on_watch_replay,
|
||||||
handle_win_summary_keyboard,
|
handle_win_summary_keyboard
|
||||||
apply_screen_shake,
|
.in_set(crate::game_plugin::NewGameRequestWriters)
|
||||||
reveal_score_breakdown,
|
.ambiguous_with(crate::game_plugin::NewGameRequestWriters)
|
||||||
|
.before(crate::ui_focus::FocusKeys),
|
||||||
|
apply_screen_shake
|
||||||
|
.ambiguous_with(crate::feedback_anim_plugin::FeedbackFx)
|
||||||
|
// Shakes the camera; hint ghosts are their own
|
||||||
|
// entity domain (#143).
|
||||||
|
.ambiguous_with(crate::feedback_anim_plugin::HintGhostFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
|
reveal_score_breakdown
|
||||||
|
.in_set(crate::ui_theme::UiTextFx)
|
||||||
|
.ambiguous_with(crate::ui_theme::UiTextFx),
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.chain()
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::animation_plugin::AnimFx),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -492,6 +560,7 @@ fn cache_win_data(
|
|||||||
pending.challenge_level = challenge_level;
|
pending.challenge_level = challenge_level;
|
||||||
pending.undo_count = game.0.undo_count();
|
pending.undo_count = game.0.undo_count();
|
||||||
pending.mode = game.0.mode;
|
pending.mode = game.0.mode;
|
||||||
|
pending.move_detail = build_move_detail(&game.0);
|
||||||
|
|
||||||
if is_new_record {
|
if is_new_record {
|
||||||
toast.write(InfoToastEvent("New Record!".to_string()));
|
toast.write(InfoToastEvent("New Record!".to_string()));
|
||||||
@@ -913,6 +982,18 @@ fn spawn_overlay(
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Move-type recap (same quiet styling as the XP breakdown)
|
||||||
|
if !pending.move_detail.is_empty() {
|
||||||
|
card.spawn((
|
||||||
|
Text::new(pending.move_detail.clone()),
|
||||||
|
TextFont {
|
||||||
|
font_size: 15.0,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Achievements unlocked this game — at most 3 shown explicitly;
|
// Achievements unlocked this game — at most 3 shown explicitly;
|
||||||
// excess is summarised with "...and N more".
|
// excess is summarised with "...and N more".
|
||||||
if !session.names.is_empty() {
|
if !session.names.is_empty() {
|
||||||
@@ -1288,6 +1369,49 @@ mod tests {
|
|||||||
assert_eq!(p.mode, GameMode::Classic);
|
assert_eq!(p.mode, GameMode::Classic);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_move_detail_fresh_game_is_empty() {
|
||||||
|
let game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||||
|
assert!(
|
||||||
|
build_move_detail(&game).is_empty(),
|
||||||
|
"no moves yet, so the recap line must be empty"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_move_detail_reports_played_move_types() {
|
||||||
|
use solitaire_core::KlondikeInstruction;
|
||||||
|
// Drive a real deal forward so the upstream counters accumulate:
|
||||||
|
// prefer foundation moves, then anything else, drawing as needed.
|
||||||
|
let mut game = GameState::new(42, solitaire_core::DrawStockConfig::DrawOne);
|
||||||
|
for _ in 0..80 {
|
||||||
|
let instructions = game.possible_instructions();
|
||||||
|
let next = instructions
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.find(|i| matches!(i, KlondikeInstruction::DstFoundation(_)))
|
||||||
|
.or_else(|| instructions.into_iter().next());
|
||||||
|
match next {
|
||||||
|
Some(i) => {
|
||||||
|
let _ = game.apply_instruction(i);
|
||||||
|
}
|
||||||
|
None => break,
|
||||||
|
}
|
||||||
|
if game.move_to_foundation_count() > 0 && game.move_to_tableau_count() > 0 {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let detail = build_move_detail(&game);
|
||||||
|
assert!(
|
||||||
|
detail.contains("to foundation"),
|
||||||
|
"seed 42 reaches a foundation move within 80 plies; got: {detail}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!detail.contains("0 "),
|
||||||
|
"zero-valued components must be omitted; got: {detail}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_xp_detail_slow_win_with_undo() {
|
fn build_xp_detail_slow_win_with_undo() {
|
||||||
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
// 300s >= 120s → no speed bonus; undo used → no no-undo bonus.
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ RUN cargo fetch --locked
|
|||||||
# src/) because solitaire_engine embeds theme/audio/font assets at compile
|
# src/) because solitaire_engine embeds theme/audio/font assets at compile
|
||||||
# time from its own assets/ and the workspace assets/.
|
# time from its own assets/ and the workspace assets/.
|
||||||
COPY build_wasm.sh ./
|
COPY build_wasm.sh ./
|
||||||
|
# whats_new_plugin embeds the changelog at compile time
|
||||||
|
# (include_str!("../../CHANGELOG.md")) — omit it and the engine build
|
||||||
|
# fails inside this stage while full-checkout builds stay green.
|
||||||
|
COPY CHANGELOG.md ./
|
||||||
COPY solitaire_core ./solitaire_core
|
COPY solitaire_core ./solitaire_core
|
||||||
COPY solitaire_sync ./solitaire_sync
|
COPY solitaire_sync ./solitaire_sync
|
||||||
COPY solitaire_data ./solitaire_data
|
COPY solitaire_data ./solitaire_data
|
||||||
|
|||||||
@@ -315,10 +315,9 @@ btnPlay.addEventListener("click", () => {
|
|||||||
}, STEP_INTERVAL_MS);
|
}, STEP_INTERVAL_MS);
|
||||||
});
|
});
|
||||||
|
|
||||||
/// Step the player back one move. Re-creates the ReplayPlayer and fast-
|
/// Step the player back one move via the wasm-side seek (rewinds to the
|
||||||
/// forwards to (step_idx - 1) without rendering intermediate frames, then
|
/// recorded deal and fast-forwards internally), then renders once so the
|
||||||
/// renders once so the CSS transition animates each card to its previous
|
/// CSS transition animates each card to its previous position.
|
||||||
/// position.
|
|
||||||
function stepBack() {
|
function stepBack() {
|
||||||
if (!player || player.step_idx() === 0) return;
|
if (!player || player.step_idx() === 0) return;
|
||||||
if (playInterval) {
|
if (playInterval) {
|
||||||
@@ -326,12 +325,7 @@ function stepBack() {
|
|||||||
playInterval = null;
|
playInterval = null;
|
||||||
btnPlay.textContent = "▶ Play";
|
btnPlay.textContent = "▶ Play";
|
||||||
}
|
}
|
||||||
const target = player.step_idx() - 1;
|
render(player.seek(player.step_idx() - 1));
|
||||||
player = new ReplayPlayer(replayJson);
|
|
||||||
for (let i = 0; i < target; i++) {
|
|
||||||
player.step();
|
|
||||||
}
|
|
||||||
render(player.state());
|
|
||||||
btnPrev.disabled = player.step_idx() === 0;
|
btnPrev.disabled = player.step_idx() === 0;
|
||||||
btnRestart.disabled = player.step_idx() === 0;
|
btnRestart.disabled = player.step_idx() === 0;
|
||||||
btnStep.disabled = false;
|
btnStep.disabled = false;
|
||||||
|
|||||||
@@ -110,6 +110,9 @@ impl From<&(Card, bool)> for CardSnapshot {
|
|||||||
#[wasm_bindgen]
|
#[wasm_bindgen]
|
||||||
pub struct ReplayPlayer {
|
pub struct ReplayPlayer {
|
||||||
game: GameState,
|
game: GameState,
|
||||||
|
/// The recorded deal before any instruction, kept so [`Self::seek_native`]
|
||||||
|
/// can rewind without reparsing the replay JSON.
|
||||||
|
initial: GameState,
|
||||||
moves: Vec<KlondikeInstruction>,
|
moves: Vec<KlondikeInstruction>,
|
||||||
step_idx: usize,
|
step_idx: usize,
|
||||||
}
|
}
|
||||||
@@ -144,12 +147,30 @@ impl ReplayPlayer {
|
|||||||
// the current build maps seeds to deals.
|
// the current build maps seeds to deals.
|
||||||
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
let (game, moves) = GameState::from_recording(&replay.recording, replay.seed, replay.mode);
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
initial: game.clone(),
|
||||||
game,
|
game,
|
||||||
moves,
|
moves,
|
||||||
step_idx: 0,
|
step_idx: 0,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jump to `step` (clamped to the move count): the board state after
|
||||||
|
/// `step` moves have been applied. Rewinds by resetting to the stored
|
||||||
|
/// initial deal, then fast-forwards — a few hundred instruction
|
||||||
|
/// applications, microseconds in practice.
|
||||||
|
pub fn seek_native(&mut self, step: usize) -> Result<StateSnapshot, MoveError> {
|
||||||
|
let target = step.min(self.moves.len());
|
||||||
|
if target < self.step_idx {
|
||||||
|
self.game = self.initial.clone();
|
||||||
|
self.step_idx = 0;
|
||||||
|
}
|
||||||
|
while self.step_idx < target {
|
||||||
|
self.game.apply_instruction(self.moves[self.step_idx])?;
|
||||||
|
self.step_idx += 1;
|
||||||
|
}
|
||||||
|
Ok(self.snapshot())
|
||||||
|
}
|
||||||
|
|
||||||
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
|
/// Apply the next move. Returns `Ok(None)` once the list is exhausted.
|
||||||
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
pub fn step_native(&mut self) -> Result<Option<StateSnapshot>, MoveError> {
|
||||||
if self.step_idx >= self.moves.len() {
|
if self.step_idx >= self.moves.len() {
|
||||||
@@ -236,6 +257,25 @@ impl ReplayPlayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Jump directly to `step` moves applied (clamped to the move count)
|
||||||
|
/// and return the snapshot there. Backwards seeks rewind to the
|
||||||
|
/// recorded deal and fast-forward, so any position is O(replay length)
|
||||||
|
/// at worst — no JSON reparse, no intermediate renders.
|
||||||
|
///
|
||||||
|
/// Throws `"replay_desync"` if a recorded move is illegal during the
|
||||||
|
/// fast-forward (corrupt recording).
|
||||||
|
pub fn seek(&mut self, step: usize) -> Result<JsValue, JsValue> {
|
||||||
|
match self.seek_native(step) {
|
||||||
|
Ok(snap) => {
|
||||||
|
serde_wasm_bindgen::to_value(&snap).map_err(|e| JsValue::from_str(&e.to_string()))
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log_replay_move_error(&e);
|
||||||
|
Err(JsValue::from_str("replay_desync"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Total number of moves the replay contains.
|
/// Total number of moves the replay contains.
|
||||||
pub fn total_steps(&self) -> usize {
|
pub fn total_steps(&self) -> usize {
|
||||||
self.moves.len()
|
self.moves.len()
|
||||||
@@ -1062,6 +1102,57 @@ mod tests {
|
|||||||
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
|
assert_eq!(orig["stock"], repl["stock"], "stock deal must match");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `seek` must land on exactly the state produced by stepping — both
|
||||||
|
/// forwards (fast-forward from the current position) and backwards
|
||||||
|
/// (rewind to the recorded deal, then fast-forward).
|
||||||
|
#[test]
|
||||||
|
fn seek_matches_stepping_in_both_directions() {
|
||||||
|
let mut game = SolitaireGame {
|
||||||
|
game: GameState::new_with_mode(51, DrawStockConfig::DrawOne, GameMode::Classic),
|
||||||
|
};
|
||||||
|
for _ in 0..24 {
|
||||||
|
let legal_moves = game.legal_moves_native();
|
||||||
|
if legal_moves.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
let idx = pick_move_index(&legal_moves).unwrap_or_default();
|
||||||
|
game.apply_legal_move_native(idx).expect("advance game");
|
||||||
|
}
|
||||||
|
let replay_json = game
|
||||||
|
.replay_export_native(60, "2026-07-10")
|
||||||
|
.expect("export replay");
|
||||||
|
|
||||||
|
let mut stepped = ReplayPlayer::from_json(&replay_json).expect("player A");
|
||||||
|
let mut seeker = ReplayPlayer::from_json(&replay_json).expect("player B");
|
||||||
|
let total = stepped.total_steps();
|
||||||
|
assert!(total >= 4, "test needs a few moves, got {total}");
|
||||||
|
|
||||||
|
// Forward: step A to k, seek B to k, compare snapshots.
|
||||||
|
let k = total / 2;
|
||||||
|
for _ in 0..k {
|
||||||
|
stepped.step_native().expect("step").expect("mid-replay");
|
||||||
|
}
|
||||||
|
let sought = seeker.seek_native(k).expect("seek forward");
|
||||||
|
assert_eq!(sought, stepped.snapshot(), "forward seek diverged at {k}");
|
||||||
|
|
||||||
|
// Backward: seek B to k - 2 and compare against a fresh stepper.
|
||||||
|
let back = k - 2;
|
||||||
|
let mut fresh = ReplayPlayer::from_json(&replay_json).expect("player C");
|
||||||
|
for _ in 0..back {
|
||||||
|
fresh.step_native().expect("step").expect("mid-replay");
|
||||||
|
}
|
||||||
|
let sought_back = seeker.seek_native(back).expect("seek backward");
|
||||||
|
assert_eq!(
|
||||||
|
sought_back,
|
||||||
|
fresh.snapshot(),
|
||||||
|
"backward seek diverged at {back}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clamping: past-the-end seeks stop at the final state.
|
||||||
|
let end = seeker.seek_native(usize::MAX).expect("seek to end");
|
||||||
|
assert_eq!(end.step_idx, total);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn debug_api_autonomous_seed_batch_smoke() {
|
fn debug_api_autonomous_seed_batch_smoke() {
|
||||||
for seed in 0_u64..128_u64 {
|
for seed in 0_u64..128_u64 {
|
||||||
|
|||||||
Reference in New Issue
Block a user