Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 36605751cd | |||
| 4d9a07727a | |||
| 513dee8fa9 | |||
| 19ddf86c7b | |||
| 28be65f092 | |||
| 65913de2cb | |||
| 25f1fd27d9 | |||
| 9a0d6496c5 | |||
| 7669a1bb56 | |||
| ddee605874 | |||
| ae8118d7df | |||
| 739c94d3bb | |||
| 3388169329 | |||
| 0e07e1d1ad | |||
| 83f37bada6 | |||
| d989d07fe7 | |||
| fa54c58bc4 | |||
| dd304913df | |||
| 48ad6b6618 | |||
| 81ac4a5383 | |||
| f51ee7b234 | |||
| fc87b13e5b | |||
| 560470b86b | |||
| 1fded5ff16 |
@@ -6,6 +6,81 @@ project follows [Semantic Versioning](https://semver.org/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.46.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Theme-store previews.** The store modal now shows each theme's
|
||||||
|
preview image next to its name — the server has advertised them since
|
||||||
|
the store launched; the client finally fetches them. Thumbnails load
|
||||||
|
in the background, pop in as they arrive, and are remembered for the
|
||||||
|
session. (#179)
|
||||||
|
- **Hint ghost preview.** Asking for a hint now also plays a translucent
|
||||||
|
copy of the suggested card gliding to its destination (twice, then it
|
||||||
|
fades) alongside the usual highlights — you see the move, not just the
|
||||||
|
pieces. Automatically disabled when reduce-motion is on. (#179)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **One toast style, one place.** Queued info banners and instant
|
||||||
|
celebration/warning/error toasts now share a single bottom-anchored
|
||||||
|
stack that clears the touch action bar; simultaneous toasts stack
|
||||||
|
upward instead of overlapping. (#178)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Touch onboarding copy.** The how-to-play slide no longer tells touch
|
||||||
|
players to left/right-click; it now describes drag, double-tap, and
|
||||||
|
the bottom-bar Hint button. (#178)
|
||||||
|
- **First launch shows one screen at a time.** On a fresh install the
|
||||||
|
Home screen no longer spawns underneath the welcome tour; the order is
|
||||||
|
now onboarding → Home → table. (#178)
|
||||||
|
|
||||||
|
## [0.45.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Thumb-reach action bar on touch (menu redesign Phase F).** The bottom
|
||||||
|
bar slims to five buttons: an enlarged **Undo · Draw · Hint** trio
|
||||||
|
(96×64 px targets) between compact Menu and Pause. **Draw is new** — it
|
||||||
|
draws from the stock exactly like tapping the deck, without the reach to
|
||||||
|
the top of a tall folded screen. Help, Modes, and New Game moved off the
|
||||||
|
touch bar (Menu → System, the Home grid, and Home's hero button cover
|
||||||
|
them). Desktop keeps its seven-button bar unchanged.
|
||||||
|
- **Hold-to-repeat Undo.** Press and hold Undo to step back repeatedly
|
||||||
|
(~5.5 undos/s after a short delay) instead of tap-tap-tap. Every step
|
||||||
|
goes through the normal undo path, so scoring penalties apply as usual.
|
||||||
|
|
||||||
|
## [0.44.0] — 2026-07-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- **Home is now a real home (menu redesign Phase B).** The mode picker became
|
||||||
|
a hierarchy: a **Continue** card (mode · elapsed · score) appears while a
|
||||||
|
game is in progress and returns to the table; a hero **New Game** button
|
||||||
|
replays your last mode with the current deal options in one tap; deal
|
||||||
|
options (draw 1/3, a new **winnable-only** toggle, difficulty tiers) moved
|
||||||
|
into a disclosure under the hero; the six modes sit in a compact symmetric
|
||||||
|
2×3 grid with descriptions on wide screens; and the stats strip moved to
|
||||||
|
the bottom. Wide viewports (desktop, unfolded foldables) get a two-pane
|
||||||
|
layout — launch surfaces left, Continue/daily/stats right. Cancel is now
|
||||||
|
**Back to table** and only appears while a live game exists. (#175)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Save files are self-contained (schema v6).** Like replays in 0.43.3, a
|
||||||
|
saved game now stores the dealt board via the upstream session serializers
|
||||||
|
instead of re-dealing from the seed on load; v4/v5 saves still load through
|
||||||
|
the legacy path and rewrite as v6 on next save. (#171)
|
||||||
|
|
||||||
|
### Internal
|
||||||
|
|
||||||
|
- **CI is ~9× faster.** Root-caused why the actions cache never restored on
|
||||||
|
the host-executor runner (per-run workdir paths poisoned the cache version
|
||||||
|
hash); the test workflow now uses a persistent on-runner target dir, and a
|
||||||
|
seconds-long `fmt` gate fails formatting mistakes before the build. Warm
|
||||||
|
full-gate runs: 4m29s, down from ~40 min. (#176)
|
||||||
|
|
||||||
## [0.43.3] — 2026-07-10
|
## [0.43.3] — 2026-07-10
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
# Menu UX Redesign — July 2026
|
# Menu UX Redesign — July 2026
|
||||||
|
|
||||||
Status: PLANNING. Visual identity (Terminal / base16-eighties) is settled and
|
Status: IN PROGRESS. A / E / C / G shipped in v0.43.0; B implemented on
|
||||||
out of scope — this is about **structure and interaction**, not colors or type.
|
`feat/home-hierarchy` (2026-07-13); F / H and the I–M backlog remain open.
|
||||||
|
Visual identity (Terminal / base16-eighties) is settled and out of
|
||||||
|
scope — this is about **structure and interaction**, not colors or type.
|
||||||
|
|
||||||
## Diagnosis (from code survey, 2026-07-07)
|
## Diagnosis (from code survey, 2026-07-07)
|
||||||
|
|
||||||
@@ -197,8 +199,13 @@ slimming), then **B**, then **F/G/H** as independent follow-ups.
|
|||||||
## Open decisions
|
## Open decisions
|
||||||
|
|
||||||
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
1. ~~Settings: tabs vs. sub-pages~~ — **DECIDED 2026-07-07: tabs.**
|
||||||
2. Deal options: disclosure on Classic card (proposed) vs. keep global row?
|
2. ~~Deal options: disclosure on Classic card vs. keep global row~~ —
|
||||||
3. Time Attack + Seed: top-level cards (proposed, grid stays symmetric) vs.
|
**DECIDED 2026-07-09: disclosure on the Classic card / New Game hero.**
|
||||||
tucked under a "More" card?
|
3. ~~Time Attack + Seed: top-level cards vs. "More" card~~ —
|
||||||
4. Stitch mockups for Phase B, or iterate directly in-engine?
|
**DECIDED 2026-07-09: top-level cards, symmetric 2×3 grid.**
|
||||||
5. Phase F bottom bar: touch-only (proposed) or also desktop?
|
4. ~~Stitch mockups for Phase B, or iterate directly in-engine~~ —
|
||||||
|
**DECIDED 2026-07-09: directly in-engine.**
|
||||||
|
5. ~~Phase F bottom bar: touch-only vs. also desktop~~ —
|
||||||
|
**DECIDED 2026-07-13: touch-only.** Desktop keeps the current top
|
||||||
|
band; the bar is additive per §3.3 so a later desktop rollout stays
|
||||||
|
a settings flag away.
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ use std::io;
|
|||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use solitaire_core::{DrawStockConfig, game_state::DifficultyLevel};
|
use solitaire_core::{
|
||||||
|
DrawStockConfig,
|
||||||
|
game_state::{DifficultyLevel, GameMode},
|
||||||
|
};
|
||||||
|
|
||||||
const SETTINGS_FILE_NAME: &str = "settings.json";
|
const SETTINGS_FILE_NAME: &str = "settings.json";
|
||||||
|
|
||||||
@@ -248,6 +251,38 @@ pub struct Settings {
|
|||||||
/// cleanly to `None` via `#[serde(default)]`.
|
/// cleanly to `None` via `#[serde(default)]`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub last_difficulty: Option<DifficultyLevel>,
|
pub last_difficulty: Option<DifficultyLevel>,
|
||||||
|
/// Mode of the last game the player launched from the home overlay.
|
||||||
|
/// The home hero "New Game" button replays this mode with one tap.
|
||||||
|
/// Older `settings.json` files written before this field existed
|
||||||
|
/// deserialize cleanly to `GameMode::Classic` via `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_mode: GameMode,
|
||||||
|
/// The release version whose "What's new" card the player has already
|
||||||
|
/// seen (e.g. `"0.46.0"`). Empty on installs that predate the card,
|
||||||
|
/// which correctly reads as "there is news to show" after an upgrade;
|
||||||
|
/// fresh installs stamp it silently when onboarding completes. Older
|
||||||
|
/// `settings.json` files deserialize cleanly to `""` via
|
||||||
|
/// `#[serde(default)]`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_seen_whats_new: String,
|
||||||
|
/// `true` once the one-shot "Stuck? Try a hint" contextual tip has
|
||||||
|
/// fired (or been suppressed as unnecessary). Phase I teach: fired
|
||||||
|
/// by the situation, shown once, like `shown_achievement_onboarding`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_stall_hint_tip: bool,
|
||||||
|
/// `true` once the one-shot radial-menu contextual tip has fired —
|
||||||
|
/// or the player has already opened the radial menu on their own,
|
||||||
|
/// which marks the tip as unnecessary without showing it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shown_radial_menu_tip: bool,
|
||||||
|
/// Global UI scale multiplier applied to all UI chrome (HUD, modals,
|
||||||
|
/// action bar) — the table itself stays window-fit via
|
||||||
|
/// `compute_layout`. Cycles through 0.9 / 1.0 / 1.15 / 1.3 in
|
||||||
|
/// Settings → Accessibility; clamped to `[UI_SCALE_MIN,
|
||||||
|
/// UI_SCALE_MAX]` by [`Settings::sanitized`]. Older `settings.json`
|
||||||
|
/// files deserialize cleanly to `1.0` via the serde default.
|
||||||
|
#[serde(default = "default_ui_scale")]
|
||||||
|
pub ui_scale: f32,
|
||||||
/// Custom public name displayed on the leaderboard. When `None`, the
|
/// Custom public name displayed on the leaderboard. When `None`, the
|
||||||
/// player's server `username` is used instead. Trimmed to 32 characters
|
/// player's server `username` is used instead. Trimmed to 32 characters
|
||||||
/// before submission. Older `settings.json` files written before this
|
/// before submission. Older `settings.json` files written before this
|
||||||
@@ -356,6 +391,16 @@ fn default_replay_move_interval_secs() -> f32 {
|
|||||||
0.45
|
0.45
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Lowest / highest UI scale the settings row offers; anything outside
|
||||||
|
/// (hand-edited settings.json) clamps here on load.
|
||||||
|
pub const UI_SCALE_MIN: f32 = 0.9;
|
||||||
|
/// See [`UI_SCALE_MIN`].
|
||||||
|
pub const UI_SCALE_MAX: f32 = 1.3;
|
||||||
|
|
||||||
|
fn default_ui_scale() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_matomo_site_id() -> u32 {
|
fn default_matomo_site_id() -> u32 {
|
||||||
1
|
1
|
||||||
}
|
}
|
||||||
@@ -415,6 +460,11 @@ impl Default for Settings {
|
|||||||
disable_smart_default_size: false,
|
disable_smart_default_size: false,
|
||||||
replay_move_interval_secs: default_replay_move_interval_secs(),
|
replay_move_interval_secs: default_replay_move_interval_secs(),
|
||||||
last_difficulty: None,
|
last_difficulty: None,
|
||||||
|
last_mode: GameMode::Classic,
|
||||||
|
last_seen_whats_new: String::new(),
|
||||||
|
shown_stall_hint_tip: false,
|
||||||
|
shown_radial_menu_tip: false,
|
||||||
|
ui_scale: default_ui_scale(),
|
||||||
leaderboard_display_name: None,
|
leaderboard_display_name: None,
|
||||||
leaderboard_opted_in: false,
|
leaderboard_opted_in: false,
|
||||||
take_from_foundation: true,
|
take_from_foundation: true,
|
||||||
@@ -450,6 +500,7 @@ impl Settings {
|
|||||||
replay_move_interval_secs: self
|
replay_move_interval_secs: self
|
||||||
.replay_move_interval_secs
|
.replay_move_interval_secs
|
||||||
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
.clamp(REPLAY_MOVE_INTERVAL_MIN_SECS, REPLAY_MOVE_INTERVAL_MAX_SECS),
|
||||||
|
ui_scale: self.ui_scale.clamp(UI_SCALE_MIN, UI_SCALE_MAX),
|
||||||
selected_theme_id,
|
selected_theme_id,
|
||||||
..self
|
..self
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ use thiserror::Error;
|
|||||||
/// so downloading it is pure waste.
|
/// so downloading it is pure waste.
|
||||||
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
|
pub const MAX_THEME_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Hard cap on a preview PNG download — previews are small decorative
|
||||||
|
/// thumbnails; anything past this is a misconfigured server.
|
||||||
|
pub const MAX_PREVIEW_BYTES: u64 = 512 * 1024;
|
||||||
|
|
||||||
/// Errors surfaced by [`ThemeStoreClient`].
|
/// Errors surfaced by [`ThemeStoreClient`].
|
||||||
#[derive(Debug, Error)]
|
#[derive(Debug, Error)]
|
||||||
pub enum ThemeStoreError {
|
pub enum ThemeStoreError {
|
||||||
@@ -110,6 +114,42 @@ impl ThemeStoreClient {
|
|||||||
verify_archive(&bytes, entry)?;
|
verify_archive(&bytes, entry)?;
|
||||||
Ok(bytes.to_vec())
|
Ok(bytes.to_vec())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetch the preview PNG the catalog advertises for `entry`.
|
||||||
|
///
|
||||||
|
/// Returns `Http(404)` when the entry carries no `preview_url` —
|
||||||
|
/// the same shape the server answers with when the file is absent,
|
||||||
|
/// so callers only handle one "no preview" case. Previews are
|
||||||
|
/// decorative; unlike archives they carry no checksum, only the
|
||||||
|
/// [`MAX_PREVIEW_BYTES`] size cap.
|
||||||
|
pub async fn fetch_preview(
|
||||||
|
&self,
|
||||||
|
entry: &ThemeCatalogEntry,
|
||||||
|
) -> Result<Vec<u8>, ThemeStoreError> {
|
||||||
|
let Some(path) = entry.preview_url.as_deref() else {
|
||||||
|
return Err(ThemeStoreError::Http(404));
|
||||||
|
};
|
||||||
|
let resp = self
|
||||||
|
.client
|
||||||
|
.get(format!("{}{}", self.base_url, path))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
if !resp.status().is_success() {
|
||||||
|
return Err(ThemeStoreError::Http(resp.status().as_u16()));
|
||||||
|
}
|
||||||
|
let bytes = resp
|
||||||
|
.bytes()
|
||||||
|
.await
|
||||||
|
.map_err(|e| ThemeStoreError::Network(e.to_string()))?;
|
||||||
|
if bytes.len() as u64 > MAX_PREVIEW_BYTES {
|
||||||
|
return Err(ThemeStoreError::Oversized {
|
||||||
|
expected: MAX_PREVIEW_BYTES,
|
||||||
|
got: bytes.len() as u64,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(bytes.to_vec())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Checks downloaded `bytes` against the catalog `entry`'s declared
|
/// Checks downloaded `bytes` against the catalog `entry`'s declared
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ use crate::events::{
|
|||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
use crate::pause_plugin::PausedResource;
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::platform::USE_TOUCH_UI_LAYOUT;
|
||||||
use crate::progress_plugin::LevelUpEvent;
|
use crate::progress_plugin::LevelUpEvent;
|
||||||
|
use crate::safe_area::SafeAreaAnchoredBottom;
|
||||||
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
use crate::settings_plugin::{SettingsChangedEvent, SettingsResource};
|
||||||
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
use crate::time_attack_plugin::TimeAttackEndedEvent;
|
||||||
use crate::ui_theme::{
|
use crate::ui_theme::{
|
||||||
@@ -160,6 +162,68 @@ pub struct ActiveToast {
|
|||||||
/// Duration of each queued info-toast in seconds.
|
/// Duration of each queued info-toast in seconds.
|
||||||
const QUEUED_TOAST_SECS: f32 = 2.5;
|
const QUEUED_TOAST_SECS: f32 = 2.5;
|
||||||
|
|
||||||
|
/// Marker on the persistent bottom-anchored flex column every toast
|
||||||
|
/// spawns into (Phase H). Stacking through one container gives queued
|
||||||
|
/// and immediate toasts a single shared anchor — simultaneous toasts
|
||||||
|
/// stack upward instead of relying on the old staggered-percentage
|
||||||
|
/// anchors to dodge each other.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastStackRoot;
|
||||||
|
|
||||||
|
/// Marker on every toast card node (both paths). Freshly spawned toasts
|
||||||
|
/// start `Visibility::Hidden` and unparented; [`adopt_toasts_into_stack`]
|
||||||
|
/// re-parents them under [`ToastStackRoot`] and reveals them — one frame
|
||||||
|
/// of latency, imperceptible at toast timescales, in exchange for the 14
|
||||||
|
/// toast handlers keeping their `Commands`-only signatures.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct ToastNode;
|
||||||
|
|
||||||
|
/// Logical-pixel gap between the screen bottom and the toast stack,
|
||||||
|
/// before safe-area insets. Clears the Phase F bottom action bar on
|
||||||
|
/// touch (compact 44px buttons + primary 64px trio + bar padding);
|
||||||
|
/// desktop's shorter bar needs less.
|
||||||
|
const TOAST_STACK_BASE_BOTTOM_PX: f32 = if USE_TOUCH_UI_LAYOUT { 112.0 } else { 72.0 };
|
||||||
|
|
||||||
|
/// Spawns the persistent [`ToastStackRoot`] container at startup.
|
||||||
|
fn spawn_toast_stack_root(mut commands: Commands) {
|
||||||
|
commands.spawn((
|
||||||
|
ToastStackRoot,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
bottom: Val::Px(TOAST_STACK_BASE_BOTTOM_PX),
|
||||||
|
left: Val::Px(0.0),
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
// Newest toast sits nearest the bottom edge; older ones
|
||||||
|
// push upward.
|
||||||
|
flex_direction: FlexDirection::ColumnReverse,
|
||||||
|
align_items: AlignItems::Center,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
SafeAreaAnchoredBottom {
|
||||||
|
base_bottom: TOAST_STACK_BASE_BOTTOM_PX,
|
||||||
|
},
|
||||||
|
ZIndex(Z_TOAST),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-parents freshly spawned [`ToastNode`]s under the stack root and
|
||||||
|
/// reveals them. No-op when every toast is already adopted.
|
||||||
|
fn adopt_toasts_into_stack(
|
||||||
|
mut commands: Commands,
|
||||||
|
orphans: Query<Entity, (With<ToastNode>, Without<ChildOf>)>,
|
||||||
|
root: Query<Entity, With<ToastStackRoot>>,
|
||||||
|
) {
|
||||||
|
let Ok(root) = root.single() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for toast in &orphans {
|
||||||
|
commands
|
||||||
|
.entity(toast)
|
||||||
|
.insert((ChildOf(root), Visibility::Inherited));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
/// Drives all linear card animations (`CardAnim`), toast notifications, deal stagger, win cascade, and the auto-complete card-slide sequence.
|
||||||
pub struct AnimationPlugin;
|
pub struct AnimationPlugin;
|
||||||
|
|
||||||
@@ -185,7 +249,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
.init_resource::<EffectiveSlideDuration>()
|
.init_resource::<EffectiveSlideDuration>()
|
||||||
.init_resource::<ToastQueue>()
|
.init_resource::<ToastQueue>()
|
||||||
.init_resource::<ActiveToast>()
|
.init_resource::<ActiveToast>()
|
||||||
.add_systems(Startup, init_slide_duration)
|
.add_systems(Startup, (init_slide_duration, spawn_toast_stack_root))
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
@@ -206,6 +270,7 @@ impl Plugin for AnimationPlugin {
|
|||||||
handle_warning_toast,
|
handle_warning_toast,
|
||||||
tick_toasts,
|
tick_toasts,
|
||||||
(enqueue_toasts, drive_toast_display).chain(),
|
(enqueue_toasts, drive_toast_display).chain(),
|
||||||
|
adopt_toasts_into_stack,
|
||||||
)
|
)
|
||||||
.after(GameMutation),
|
.after(GameMutation),
|
||||||
);
|
);
|
||||||
@@ -637,26 +702,14 @@ impl ToastVariant {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawns a bottom-anchored `ToastEntity` for the queued toast system.
|
/// Spawns a `ToastEntity` for the queued toast system.
|
||||||
///
|
///
|
||||||
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
/// Queued toasts always carry [`ToastVariant::Info`] — the queue is fed
|
||||||
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
/// by [`InfoToastEvent`] which is by definition neutral system info.
|
||||||
/// Variants other than `Info` belong on the immediate-fire path
|
/// Variants other than `Info` belong on the immediate-fire path
|
||||||
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
/// ([`spawn_toast`]) where the call site knows the semantic intent.
|
||||||
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
fn spawn_queued_toast(commands: &mut Commands, message: String) -> Entity {
|
||||||
spawn_toast_node(
|
spawn_toast_node(commands, ToastEntity, message, ToastVariant::Info)
|
||||||
commands,
|
|
||||||
ToastEntity,
|
|
||||||
message,
|
|
||||||
ToastVariant::Info,
|
|
||||||
// Slightly taller anchor than the immediate-fire path so a
|
|
||||||
// queued info banner doesn't collide with a celebration toast
|
|
||||||
// fired in the same frame.
|
|
||||||
Val::Percent(6.0),
|
|
||||||
Val::Percent(15.0),
|
|
||||||
Val::Percent(70.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_2),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
fn handle_xp_awarded_toast(mut commands: Commands, mut events: MessageReader<XpAwardedEvent>) {
|
||||||
@@ -744,12 +797,6 @@ fn spawn_toast(
|
|||||||
(ToastOverlay, ToastTimer(duration_secs)),
|
(ToastOverlay, ToastTimer(duration_secs)),
|
||||||
message,
|
message,
|
||||||
variant,
|
variant,
|
||||||
// Sits above the queued banner so a celebration toast spawned
|
|
||||||
// alongside a queued info message remains readable.
|
|
||||||
Val::Percent(14.0),
|
|
||||||
Val::Percent(25.0),
|
|
||||||
Val::Percent(50.0),
|
|
||||||
UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -766,31 +813,25 @@ fn spawn_toast(
|
|||||||
/// rungs; 18 is the closest rung that preserves the scale invariants
|
/// rungs; 18 is the closest rung that preserves the scale invariants
|
||||||
/// tested in `ui_theme::tests`.
|
/// tested in `ui_theme::tests`.
|
||||||
/// - [`RADIUS_MD`] corners.
|
/// - [`RADIUS_MD`] corners.
|
||||||
/// - Bottom-anchored absolute position; `bottom_pct` differs between
|
///
|
||||||
/// queued and immediate paths so they layer instead of overlap.
|
/// Layout is owned by [`ToastStackRoot`] (Phase H): the node spawns
|
||||||
// The 8-argument signature is intentional — these are the per-toast
|
/// hidden and unpositioned, and [`adopt_toasts_into_stack`] slots it
|
||||||
// layout values that genuinely differ between the queued and fire-and-
|
/// into the shared bottom-anchored column — one anchor for every toast,
|
||||||
// forget call sites. A struct wrapper would just rename the same data.
|
/// simultaneous toasts stack instead of overlapping.
|
||||||
#[allow(clippy::too_many_arguments)]
|
|
||||||
fn spawn_toast_node<B: Bundle>(
|
fn spawn_toast_node<B: Bundle>(
|
||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
bundle: B,
|
bundle: B,
|
||||||
message: String,
|
message: String,
|
||||||
variant: ToastVariant,
|
variant: ToastVariant,
|
||||||
bottom_pct: Val,
|
|
||||||
left_pct: Val,
|
|
||||||
width_pct: Val,
|
|
||||||
padding: UiRect,
|
|
||||||
) -> Entity {
|
) -> Entity {
|
||||||
commands
|
commands
|
||||||
.spawn((
|
.spawn((
|
||||||
bundle,
|
bundle,
|
||||||
|
ToastNode,
|
||||||
|
Visibility::Hidden,
|
||||||
Node {
|
Node {
|
||||||
position_type: PositionType::Absolute,
|
max_width: Val::Percent(70.0),
|
||||||
left: left_pct,
|
padding: UiRect::axes(VAL_SPACE_4, VAL_SPACE_3),
|
||||||
bottom: bottom_pct,
|
|
||||||
width: width_pct,
|
|
||||||
padding,
|
|
||||||
justify_content: JustifyContent::Center,
|
justify_content: JustifyContent::Center,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
@@ -799,7 +840,6 @@ fn spawn_toast_node<B: Bundle>(
|
|||||||
},
|
},
|
||||||
BackgroundColor(BG_ELEVATED),
|
BackgroundColor(BG_ELEVATED),
|
||||||
BorderColor::all(variant.border_color()),
|
BorderColor::all(variant.border_color()),
|
||||||
ZIndex(Z_TOAST),
|
|
||||||
))
|
))
|
||||||
.with_children(|b| {
|
.with_children(|b| {
|
||||||
b.spawn((
|
b.spawn((
|
||||||
@@ -1315,4 +1355,85 @@ mod tests {
|
|||||||
fn cascade_duration_instant_is_zero() {
|
fn cascade_duration_instant_is_zero() {
|
||||||
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
assert_eq!(cascade_duration_secs(AnimSpeed::Instant), 0.0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H: unified toast stack
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Both toast paths must end up as visible children of the single
|
||||||
|
/// [`ToastStackRoot`] — the Phase H "one anchor" contract.
|
||||||
|
#[test]
|
||||||
|
fn queued_and_immediate_toasts_stack_under_one_root() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update(); // Startup: spawns the stack root.
|
||||||
|
|
||||||
|
// One immediate (error) toast + one queued (info) toast in the
|
||||||
|
// same frame — the exact collision case the old staggered
|
||||||
|
// anchors existed to dodge.
|
||||||
|
use solitaire_core::{KlondikePile, Tableau};
|
||||||
|
app.world_mut().write_message(MoveRejectedEvent {
|
||||||
|
from: KlondikePile::Tableau(Tableau::Tableau1),
|
||||||
|
to: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(InfoToastEvent("stacked info".to_string()));
|
||||||
|
app.update(); // handlers spawn both toasts (hidden, unparented)
|
||||||
|
app.update(); // adopt_toasts_into_stack re-parents + reveals
|
||||||
|
|
||||||
|
let root = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<ToastStackRoot>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("exactly one ToastStackRoot must exist");
|
||||||
|
|
||||||
|
let toasts: Vec<(Entity, &ChildOf, &Visibility)> = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<(Entity, &ChildOf, &Visibility), With<ToastNode>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
toasts.len(),
|
||||||
|
2,
|
||||||
|
"both the immediate and the queued toast must be adopted"
|
||||||
|
);
|
||||||
|
for (entity, child_of, visibility) in toasts {
|
||||||
|
assert_eq!(
|
||||||
|
child_of.parent(),
|
||||||
|
root,
|
||||||
|
"toast {entity} must be a child of the shared stack root"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
*visibility,
|
||||||
|
Visibility::Inherited,
|
||||||
|
"adopted toast {entity} must be revealed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toast nodes must not carry their own absolute positioning — the
|
||||||
|
/// stack root owns layout (regression guard against reintroducing
|
||||||
|
/// per-path anchors).
|
||||||
|
#[test]
|
||||||
|
fn toast_nodes_have_no_absolute_position() {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(AnimationPlugin);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.write_message(WarningToastEvent("layout check".to_string()));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let node = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Node, With<ToastNode>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("warning toast must spawn a ToastNode");
|
||||||
|
assert_eq!(
|
||||||
|
node.position_type,
|
||||||
|
PositionType::Relative,
|
||||||
|
"toast nodes are flex children of the stack, not absolute overlays"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
//! Hold-`/` hotkey cheat sheet (Phase J).
|
||||||
|
//!
|
||||||
|
//! While `/` is held on a keyboard platform, a lightweight overlay
|
||||||
|
//! lists every binding from [`crate::hotkeys::HOTKEYS`] — the same
|
||||||
|
//! table the onboarding slide teaches from, so the two can never
|
||||||
|
//! disagree. Releasing the key hides it instantly; it never captures
|
||||||
|
//! input, never pauses the game, and never spawns while a modal owns
|
||||||
|
//! the screen (which also keeps it out of the seed-entry text field's
|
||||||
|
//! way).
|
||||||
|
//!
|
||||||
|
//! Not a `spawn_modal` modal on purpose: modals are sticky and guarded;
|
||||||
|
//! this is a momentary reference card, closer to a tooltip than a
|
||||||
|
//! dialog.
|
||||||
|
|
||||||
|
use bevy::input::ButtonInput;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::hotkeys::HOTKEYS;
|
||||||
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
use crate::ui_theme::{
|
||||||
|
ACCENT_PRIMARY, BG_ELEVATED, BORDER_STRONG, HighContrastBorder, RADIUS_MD, TEXT_PRIMARY,
|
||||||
|
TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, VAL_SPACE_3, VAL_SPACE_4,
|
||||||
|
Z_TOOLTIP,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Marker on the cheat-sheet overlay root.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct CheatSheetOverlay;
|
||||||
|
|
||||||
|
/// Registers the hold-`/` driver. Inert on touch-first builds.
|
||||||
|
pub struct CheatSheetPlugin;
|
||||||
|
|
||||||
|
impl Plugin for CheatSheetPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<ButtonInput<KeyCode>>()
|
||||||
|
.add_systems(Update, drive_cheat_sheet);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the overlay while `/` is held (and no modal owns the screen);
|
||||||
|
/// hides it the frame the key releases.
|
||||||
|
fn drive_cheat_sheet(
|
||||||
|
keys: Res<ButtonInput<KeyCode>>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
existing: Query<Entity, With<CheatSheetOverlay>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if !SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let held = keys.pressed(KeyCode::Slash);
|
||||||
|
if held && existing.is_empty() && scrims.is_empty() {
|
||||||
|
spawn_cheat_sheet(&mut commands, font_res.as_deref());
|
||||||
|
} else if !held {
|
||||||
|
for entity in &existing {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_cheat_sheet(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_header = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_keys = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_desc = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
commands
|
||||||
|
.spawn((
|
||||||
|
CheatSheetOverlay,
|
||||||
|
Node {
|
||||||
|
position_type: PositionType::Absolute,
|
||||||
|
right: Val::Px(16.0),
|
||||||
|
top: Val::Percent(8.0),
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_1,
|
||||||
|
padding: UiRect::all(VAL_SPACE_4),
|
||||||
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
|
border_radius: BorderRadius::all(Val::Px(RADIUS_MD)),
|
||||||
|
max_height: Val::Percent(84.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
BackgroundColor(BG_ELEVATED),
|
||||||
|
BorderColor::all(BORDER_STRONG),
|
||||||
|
HighContrastBorder::with_default(BORDER_STRONG),
|
||||||
|
GlobalZIndex(Z_TOOLTIP),
|
||||||
|
))
|
||||||
|
.with_children(|panel| {
|
||||||
|
panel.spawn((
|
||||||
|
Text::new("Keyboard shortcuts"),
|
||||||
|
font_header.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
Node {
|
||||||
|
margin: UiRect::bottom(VAL_SPACE_2),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
for row in HOTKEYS {
|
||||||
|
panel
|
||||||
|
.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Row,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|line| {
|
||||||
|
line.spawn((
|
||||||
|
Text::new(row.keys),
|
||||||
|
font_keys.clone(),
|
||||||
|
TextColor(ACCENT_PRIMARY),
|
||||||
|
Node {
|
||||||
|
min_width: Val::Px(110.0),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
line.spawn((
|
||||||
|
Text::new(row.description),
|
||||||
|
font_desc.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn app() -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(CheatSheetPlugin);
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn overlay_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&CheatSheetOverlay>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn holding_slash_shows_and_release_hides() {
|
||||||
|
let mut app = app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Slash);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(overlay_count(&mut app), 1, "held / must show the sheet");
|
||||||
|
// Still held on later frames: exactly one overlay, no stacking.
|
||||||
|
app.update();
|
||||||
|
assert_eq!(overlay_count(&mut app), 1);
|
||||||
|
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.release(KeyCode::Slash);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(overlay_count(&mut app), 0, "release must hide the sheet");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn suppressed_while_a_modal_is_open() {
|
||||||
|
let mut app = app();
|
||||||
|
app.world_mut().spawn(ModalScrim);
|
||||||
|
app.update();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<ButtonInput<KeyCode>>()
|
||||||
|
.press(KeyCode::Slash);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
overlay_count(&mut app),
|
||||||
|
0,
|
||||||
|
"the sheet must not spawn over a modal (or into a text field)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,309 @@
|
|||||||
|
//! Contextual one-time tips (Phase I of the 2026-07 UI redesign).
|
||||||
|
//!
|
||||||
|
//! Everything the first-run onboarding doesn't cover is invisible until
|
||||||
|
//! stumbled upon: hint cycling, the radial quick-action menu. These
|
||||||
|
//! tips fire **from the situation, not a tour** — each exactly once,
|
||||||
|
//! recorded in `Settings` like `shown_achievement_onboarding`:
|
||||||
|
//!
|
||||||
|
//! - **Stall tip** — after [`STALL_TIP_SECS`] with no board change in an
|
||||||
|
//! active game, an info toast points at Hint. A player staring at a
|
||||||
|
//! stuck board is the one moment the tip is welcome.
|
||||||
|
//! - **Radial tip** — once a game reaches [`RADIAL_TIP_MIN_MOVES`] moves
|
||||||
|
//! (an engaged player) and the radial menu has never been opened, an
|
||||||
|
//! info toast teaches the long-press / right-click gesture. Opening
|
||||||
|
//! the radial organically marks the tip as unnecessary — it is never
|
||||||
|
//! shown to someone who already knows.
|
||||||
|
//!
|
||||||
|
//! Tips render through the queued [`InfoToastEvent`] path, so they share
|
||||||
|
//! the unified toast stack and never interrupt play.
|
||||||
|
|
||||||
|
use bevy::prelude::*;
|
||||||
|
|
||||||
|
use solitaire_data::save_settings_to;
|
||||||
|
|
||||||
|
use crate::events::{InfoToastEvent, StateChangedEvent};
|
||||||
|
use crate::game_plugin::GameMutation;
|
||||||
|
use crate::pause_plugin::PausedResource;
|
||||||
|
use crate::platform::SHOW_KEYBOARD_ACCELERATORS;
|
||||||
|
use crate::radial_menu::RightClickRadialState;
|
||||||
|
use crate::resources::GameStateResource;
|
||||||
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
|
use crate::ui_modal::ModalScrim;
|
||||||
|
|
||||||
|
/// Seconds without any board change before the stall tip fires.
|
||||||
|
/// Long enough that normal thinking never triggers it; short enough to
|
||||||
|
/// reach a genuinely stuck player before they quit.
|
||||||
|
const STALL_TIP_SECS: f32 = 45.0;
|
||||||
|
|
||||||
|
/// Move count at which an engaged player earns the radial-menu teach.
|
||||||
|
const RADIAL_TIP_MIN_MOVES: u32 = 15;
|
||||||
|
|
||||||
|
/// Stall-tip copy per platform input vocabulary.
|
||||||
|
const STALL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
"Stuck? Press H for a hint."
|
||||||
|
} else {
|
||||||
|
"Stuck? Tap Hint in the bottom bar for a suggested move."
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Radial-tip copy per platform input vocabulary.
|
||||||
|
const RADIAL_TIP: &str = if SHOW_KEYBOARD_ACCELERATORS {
|
||||||
|
"Tip: right-click a card for quick actions."
|
||||||
|
} else {
|
||||||
|
"Tip: long-press a card for quick actions."
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Seconds of board inactivity, frozen while paused / a modal is open,
|
||||||
|
/// reset by every [`StateChangedEvent`].
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct StallClock(pub f32);
|
||||||
|
|
||||||
|
/// Registers the stall clock and the two tip triggers.
|
||||||
|
pub struct ContextualTipsPlugin;
|
||||||
|
|
||||||
|
impl Plugin for ContextualTipsPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<StallClock>()
|
||||||
|
.add_message::<InfoToastEvent>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
tick_stall_clock,
|
||||||
|
fire_stall_hint_tip
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
observe_radial_menu_use,
|
||||||
|
fire_radial_menu_tip
|
||||||
|
.in_set(crate::game_plugin::InfoToastWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::InfoToastWriters),
|
||||||
|
)
|
||||||
|
.chain()
|
||||||
|
.after(GameMutation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances the stall clock; resets on any board change; freezes while
|
||||||
|
/// paused, while a modal owns the screen, or when the game is over.
|
||||||
|
fn tick_stall_clock(
|
||||||
|
time: Res<Time>,
|
||||||
|
mut state_events: MessageReader<StateChangedEvent>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
scrims: Query<(), With<ModalScrim>>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut clock: ResMut<StallClock>,
|
||||||
|
) {
|
||||||
|
if state_events.read().next().is_some() {
|
||||||
|
clock.0 = 0.0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if paused.is_some_and(|p| p.0)
|
||||||
|
|| !scrims.is_empty()
|
||||||
|
|| game.as_ref().is_none_or(|g| g.0.is_won())
|
||||||
|
{
|
||||||
|
// Frozen, not reset: backgrounding into a menu mid-stall
|
||||||
|
// shouldn't restart the wait.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
clock.0 += time.delta_secs();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires the one-shot stall tip once the clock passes the threshold in
|
||||||
|
/// a game the player has actually started (at least one move).
|
||||||
|
fn fire_stall_hint_tip(
|
||||||
|
clock: Res<StallClock>,
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let Some(s) = settings.as_mut() else { return };
|
||||||
|
if s.0.shown_stall_hint_tip || clock.0 < STALL_TIP_SECS {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if game
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|g| g.0.move_count() == 0 || g.0.is_won())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.write(InfoToastEvent(STALL_TIP.to_string()));
|
||||||
|
s.0.shown_stall_hint_tip = true;
|
||||||
|
persist(storage_path.as_deref(), &s.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A player who opens the radial menu on their own doesn't need the
|
||||||
|
/// teach — mark the tip done silently.
|
||||||
|
fn observe_radial_menu_use(
|
||||||
|
radial: Option<Res<RightClickRadialState>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
) {
|
||||||
|
let Some(radial) = radial else { return };
|
||||||
|
if !radial.is_active() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(s) = settings.as_mut() else { return };
|
||||||
|
if s.0.shown_radial_menu_tip {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s.0.shown_radial_menu_tip = true;
|
||||||
|
persist(storage_path.as_deref(), &s.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fires the one-shot radial teach for an engaged player who has never
|
||||||
|
/// opened the menu themselves.
|
||||||
|
fn fire_radial_menu_tip(
|
||||||
|
game: Option<Res<GameStateResource>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
mut toast: MessageWriter<InfoToastEvent>,
|
||||||
|
) {
|
||||||
|
let Some(s) = settings.as_mut() else { return };
|
||||||
|
if s.0.shown_radial_menu_tip {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if game
|
||||||
|
.as_ref()
|
||||||
|
.is_none_or(|g| g.0.move_count() < RADIAL_TIP_MIN_MOVES || g.0.is_won())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.write(InfoToastEvent(RADIAL_TIP.to_string()));
|
||||||
|
s.0.shown_radial_menu_tip = true;
|
||||||
|
persist(storage_path.as_deref(), &s.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persist(storage_path: Option<&SettingsStoragePath>, settings: &solitaire_data::Settings) {
|
||||||
|
if let Some(p) = storage_path
|
||||||
|
&& let Some(path) = p.0.as_deref()
|
||||||
|
&& let Err(e) = save_settings_to(path, settings)
|
||||||
|
{
|
||||||
|
warn!("contextual tips: failed to persist tip flag: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use bevy::ecs::message::Messages;
|
||||||
|
use solitaire_core::{DrawStockConfig, game_state::GameState};
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
fn app_with(settings: Settings, game: GameState) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(ContextualTipsPlugin);
|
||||||
|
app.insert_resource(SettingsResource(settings));
|
||||||
|
app.insert_resource(GameStateResource(game));
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn info_toast_count(app: &App) -> usize {
|
||||||
|
let events = app.world().resource::<Messages<InfoToastEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
cursor.read(events).count()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn started_game() -> GameState {
|
||||||
|
let mut game = GameState::new(7, DrawStockConfig::DrawOne);
|
||||||
|
game.draw().expect("draw from fresh deal");
|
||||||
|
game
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_tip_fires_once_past_threshold() {
|
||||||
|
let mut app = app_with(Settings::default(), started_game());
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(info_toast_count(&app), 1, "stall tip must fire");
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_stall_hint_tip,
|
||||||
|
"the tip flag must set so it never repeats"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 30.0;
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<InfoToastEvent>>()
|
||||||
|
.clear();
|
||||||
|
app.update();
|
||||||
|
assert_eq!(info_toast_count(&app), 0, "the tip is one-shot");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_tip_needs_a_started_unwon_game() {
|
||||||
|
// Untouched deal: staring at the launch screen is not a stall.
|
||||||
|
let mut app = app_with(
|
||||||
|
Settings::default(),
|
||||||
|
GameState::new(7, DrawStockConfig::DrawOne),
|
||||||
|
);
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(info_toast_count(&app), 0, "no tip on an untouched deal");
|
||||||
|
|
||||||
|
// Won game: nothing to hint at.
|
||||||
|
let mut won = started_game();
|
||||||
|
won.set_test_won(true);
|
||||||
|
let mut app = app_with(Settings::default(), won);
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = STALL_TIP_SECS + 1.0;
|
||||||
|
app.update();
|
||||||
|
assert_eq!(info_toast_count(&app), 0, "no tip on a won game");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stall_clock_resets_on_state_change() {
|
||||||
|
let mut app = app_with(Settings::default(), started_game());
|
||||||
|
app.world_mut().resource_mut::<StallClock>().0 = 30.0;
|
||||||
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<StallClock>().0,
|
||||||
|
0.0,
|
||||||
|
"any board change must reset the stall clock"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_tip_fires_for_engaged_player() {
|
||||||
|
let mut game = started_game();
|
||||||
|
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
|
||||||
|
let mut app = app_with(Settings::default(), game);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
info_toast_count(&app),
|
||||||
|
1,
|
||||||
|
"radial tip must fire at the move threshold"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.shown_radial_menu_tip
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn radial_tip_suppressed_after_organic_use() {
|
||||||
|
let mut game = started_game();
|
||||||
|
game.set_test_move_count(RADIAL_TIP_MIN_MOVES);
|
||||||
|
let mut app = app_with(
|
||||||
|
Settings {
|
||||||
|
shown_radial_menu_tip: true,
|
||||||
|
..Settings::default()
|
||||||
|
},
|
||||||
|
game,
|
||||||
|
);
|
||||||
|
app.update();
|
||||||
|
assert_eq!(
|
||||||
|
info_toast_count(&app),
|
||||||
|
0,
|
||||||
|
"a player who used the radial menu never sees the teach"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,14 +14,14 @@ use crate::platform::{
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
AchievementPlugin, AnimationPlugin, AssetSourcesPlugin, AutoCompletePlugin,
|
||||||
CardAnimationPlugin, CardPlugin, ChallengePlugin, CursorPlugin, DailyChallengePlugin,
|
CardAnimationPlugin, CardPlugin, ChallengePlugin, CheatSheetPlugin, ContextualTipsPlugin,
|
||||||
DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin, FontPlugin, GamePlugin, HelpPlugin,
|
CursorPlugin, DailyChallengePlugin, DiagnosticsHudPlugin, DifficultyPlugin, FeedbackAnimPlugin,
|
||||||
HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin, PausePlugin, PlayBySeedPlugin,
|
FontPlugin, GamePlugin, HelpPlugin, HomePlugin, HudPlugin, InputPlugin, OnboardingPlugin,
|
||||||
ProfilePlugin, ProgressPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin,
|
PausePlugin, PlayBySeedPlugin, ProfilePlugin, ProgressPlugin, RadialMenuPlugin,
|
||||||
SafeAreaInsetsPlugin, SelectionPlugin, SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin,
|
ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, SelectionPlugin,
|
||||||
StatsPlugin, SyncProvider, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
|
SettingsPlugin, SolutionPlaybackPlugin, SplashPlugin, StatsPlugin, SyncProvider, TablePlugin,
|
||||||
TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
|
ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, TouchSelectionPlugin, UiFocusPlugin,
|
||||||
WinSummaryPlugin,
|
UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WhatsNewPlugin, WinSummaryPlugin,
|
||||||
};
|
};
|
||||||
#[cfg(not(target_arch = "wasm32"))]
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -115,6 +115,9 @@ impl Plugin for CoreGamePlugin {
|
|||||||
.add_plugins(PausePlugin)
|
.add_plugins(PausePlugin)
|
||||||
.add_plugins(SettingsPlugin::default())
|
.add_plugins(SettingsPlugin::default())
|
||||||
.add_plugins(OnboardingPlugin)
|
.add_plugins(OnboardingPlugin)
|
||||||
|
.add_plugins(WhatsNewPlugin)
|
||||||
|
.add_plugins(ContextualTipsPlugin)
|
||||||
|
.add_plugins(CheatSheetPlugin)
|
||||||
.add_plugins(WinSummaryPlugin)
|
.add_plugins(WinSummaryPlugin)
|
||||||
.add_plugins(UiModalPlugin)
|
.add_plugins(UiModalPlugin)
|
||||||
.add_plugins(UiFocusPlugin)
|
.add_plugins(UiFocusPlugin)
|
||||||
|
|||||||
@@ -49,10 +49,11 @@ use solitaire_core::klondike_adapter::foundation_from_slot;
|
|||||||
use solitaire_data::AnimSpeed;
|
use solitaire_data::AnimSpeed;
|
||||||
|
|
||||||
use crate::animation_plugin::CardAnim;
|
use crate::animation_plugin::CardAnim;
|
||||||
use crate::card_plugin::CardEntity;
|
use crate::card_animation::{MotionCurve, sample_curve};
|
||||||
|
use crate::card_plugin::{CardEntity, CardEntityIndex};
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
DrawRequestEvent, FoundationCompletedEvent, MoveRejectedEvent, MoveRequestEvent,
|
DrawRequestEvent, FoundationCompletedEvent, HintVisualEvent, MoveRejectedEvent,
|
||||||
NewGameRequestEvent,
|
MoveRequestEvent, NewGameRequestEvent, StateChangedEvent,
|
||||||
};
|
};
|
||||||
use crate::game_plugin::GameMutation;
|
use crate::game_plugin::GameMutation;
|
||||||
use crate::layout::LayoutResource;
|
use crate::layout::LayoutResource;
|
||||||
@@ -207,6 +208,8 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
.add_message::<MoveRejectedEvent>()
|
.add_message::<MoveRejectedEvent>()
|
||||||
.add_message::<NewGameRequestEvent>()
|
.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<FoundationCompletedEvent>()
|
.add_message::<FoundationCompletedEvent>()
|
||||||
|
.add_message::<HintVisualEvent>()
|
||||||
|
.add_message::<StateChangedEvent>()
|
||||||
.add_message::<RequestRedraw>()
|
.add_message::<RequestRedraw>()
|
||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
@@ -224,6 +227,20 @@ impl Plugin for FeedbackAnimPlugin {
|
|||||||
start_deal_anim.after(GameMutation),
|
start_deal_anim.after(GameMutation),
|
||||||
start_foundation_flourish.after(GameMutation),
|
start_foundation_flourish.after(GameMutation),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
// Hint ghost (Phase H): the spawn reads card Transform/Sprite,
|
||||||
|
// so it orders after the board painters; the tick only touches
|
||||||
|
// ghost entities (Without<CardEntity>) and stays conflict-free.
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(
|
||||||
|
spawn_hint_ghost
|
||||||
|
.after(GameMutation)
|
||||||
|
.after(crate::card_plugin::BoardVisuals),
|
||||||
|
tick_hint_ghosts,
|
||||||
|
despawn_hint_ghosts_on_state_change.after(GameMutation),
|
||||||
|
)
|
||||||
|
.chain(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,6 +681,147 @@ fn pile_cards(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase H — hint ghost-motion preview
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Duration of one ghost glide from the hinted card to its destination.
|
||||||
|
const HINT_GHOST_PASS_SECS: f32 = 0.7;
|
||||||
|
/// How many glides one hint plays before the ghost despawns. Two reads
|
||||||
|
/// as "this move, over there" without outstaying the 2 s static
|
||||||
|
/// highlight it accompanies.
|
||||||
|
const HINT_GHOST_PASSES: f32 = 2.0;
|
||||||
|
/// Ghost translucency — clearly a projection, never mistakable for the
|
||||||
|
/// real card.
|
||||||
|
const HINT_GHOST_ALPHA: f32 = 0.45;
|
||||||
|
/// Ghost render depth: above every settled pile (~1.04 max) and the
|
||||||
|
/// in-flight `CardAnim` lift (50), below a dragged card (500).
|
||||||
|
const HINT_GHOST_Z: f32 = 400.0;
|
||||||
|
|
||||||
|
/// A translucent copy of the hinted card gliding to the suggested
|
||||||
|
/// destination (Phase H). Purely decorative — despawned by timer, by a
|
||||||
|
/// newer hint, or by any state change.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct HintGhost {
|
||||||
|
start: Vec3,
|
||||||
|
target: Vec3,
|
||||||
|
elapsed: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalised progress of the current glide pass, restarting from the
|
||||||
|
/// source each pass. Pure for unit testing.
|
||||||
|
fn hint_ghost_pass_t(elapsed: f32) -> f32 {
|
||||||
|
(elapsed % HINT_GHOST_PASS_SECS) / HINT_GHOST_PASS_SECS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Ghost alpha at `pass_t` — full strength for most of the glide, then
|
||||||
|
/// fading over the last 20 % so the loop restart reads as a repeat
|
||||||
|
/// rather than a teleport. Pure for unit testing.
|
||||||
|
fn hint_ghost_alpha(pass_t: f32) -> f32 {
|
||||||
|
let fade_in_tail = ((pass_t - 0.8) / 0.2).clamp(0.0, 1.0);
|
||||||
|
HINT_GHOST_ALPHA * (1.0 - fade_in_tail)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the ghost when a hint fires. The static highlights (source
|
||||||
|
/// card + gold destination pile) still spawn regardless; under
|
||||||
|
/// reduce-motion they are the whole story and no ghost appears
|
||||||
|
/// (`design-system.md` §Accessibility).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn spawn_hint_ghost(
|
||||||
|
mut events: MessageReader<HintVisualEvent>,
|
||||||
|
settings: Option<Res<SettingsResource>>,
|
||||||
|
index: Option<Res<CardEntityIndex>>,
|
||||||
|
layout: Option<Res<LayoutResource>>,
|
||||||
|
cards: Query<(&Transform, &Sprite), With<CardEntity>>,
|
||||||
|
existing: Query<Entity, With<HintGhost>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if settings.is_some_and(|s| s.0.reduce_motion_mode) {
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (Some(index), Some(layout)) = (index, layout) else {
|
||||||
|
events.clear();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for ev in events.read() {
|
||||||
|
// A fresh hint replaces any ghost still in flight.
|
||||||
|
for entity in &existing {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
let Some(card_entity) = index.get(&ev.source_card) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Ok((transform, sprite)) = cards.get(card_entity) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let Some(&dest) = layout.0.pile_positions.get(&ev.dest_pile) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let start = transform.translation.truncate().extend(HINT_GHOST_Z);
|
||||||
|
let mut ghost_sprite = sprite.clone();
|
||||||
|
ghost_sprite.color = ghost_sprite.color.with_alpha(HINT_GHOST_ALPHA);
|
||||||
|
commands.spawn((
|
||||||
|
HintGhost {
|
||||||
|
start,
|
||||||
|
target: dest.extend(HINT_GHOST_Z),
|
||||||
|
elapsed: 0.0,
|
||||||
|
},
|
||||||
|
ghost_sprite,
|
||||||
|
Transform::from_translation(start),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Advances every ghost: eased glide per pass, tail fade, despawn after
|
||||||
|
/// [`HINT_GHOST_PASSES`]. Frozen while paused, like every other
|
||||||
|
/// decorative animation.
|
||||||
|
#[allow(clippy::type_complexity)]
|
||||||
|
fn tick_hint_ghosts(
|
||||||
|
time: Res<Time>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut ghosts: Query<
|
||||||
|
(Entity, &mut HintGhost, &mut Transform, &mut Sprite),
|
||||||
|
(Without<CardEntity>, Without<PileMarker>),
|
||||||
|
>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let dt = time.delta_secs();
|
||||||
|
for (entity, mut ghost, mut transform, mut sprite) in ghosts.iter_mut() {
|
||||||
|
ghost.elapsed += dt;
|
||||||
|
if ghost.elapsed >= HINT_GHOST_PASS_SECS * HINT_GHOST_PASSES {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let pass_t = hint_ghost_pass_t(ghost.elapsed);
|
||||||
|
let eased = sample_curve(MotionCurve::SmoothSnap, pass_t);
|
||||||
|
transform.translation = ghost.start.lerp(ghost.target, eased);
|
||||||
|
sprite.color = sprite.color.with_alpha(hint_ghost_alpha(pass_t));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A ghost previews a move against the *current* board; the moment the
|
||||||
|
/// board changes (move, undo, draw, new game) it is stale and vanishes.
|
||||||
|
fn despawn_hint_ghosts_on_state_change(
|
||||||
|
mut events: MessageReader<StateChangedEvent>,
|
||||||
|
ghosts: Query<Entity, With<HintGhost>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
) {
|
||||||
|
if events.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
events.clear();
|
||||||
|
for entity in &ghosts {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Unit tests (pure functions only — no Bevy world required)
|
// Unit tests (pure functions only — no Bevy world required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -955,4 +1113,147 @@ mod tests {
|
|||||||
"FoundationFlourish must not be inserted under reduce-motion"
|
"FoundationFlourish must not be inserted under reduce-motion"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H — hint ghost-motion preview
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_pass_t_restarts_each_pass() {
|
||||||
|
assert_eq!(hint_ghost_pass_t(0.0), 0.0);
|
||||||
|
// Just past one full pass, progress wraps back near zero.
|
||||||
|
assert!(hint_ghost_pass_t(HINT_GHOST_PASS_SECS + 0.01) < 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_alpha_fades_only_in_the_tail() {
|
||||||
|
assert_eq!(hint_ghost_alpha(0.0), HINT_GHOST_ALPHA);
|
||||||
|
assert_eq!(hint_ghost_alpha(0.79), HINT_GHOST_ALPHA);
|
||||||
|
assert!(hint_ghost_alpha(0.9) < HINT_GHOST_ALPHA);
|
||||||
|
assert!(hint_ghost_alpha(1.0).abs() < 1e-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// App with one indexed card entity and a real layout, ready to
|
||||||
|
/// receive `HintVisualEvent`s.
|
||||||
|
fn ghost_app(reduce_motion: bool) -> (App, Card) {
|
||||||
|
use solitaire_core::{Deck, Rank, Suit};
|
||||||
|
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins)
|
||||||
|
.add_plugins(FeedbackAnimPlugin);
|
||||||
|
// Sibling systems in the plugin take GameStateResource
|
||||||
|
// non-optionally; give them a fresh deal.
|
||||||
|
app.insert_resource(GameStateResource(
|
||||||
|
solitaire_core::game_state::GameState::new(1, solitaire_core::DrawStockConfig::DrawOne),
|
||||||
|
));
|
||||||
|
app.insert_resource(SettingsResource(solitaire_data::Settings {
|
||||||
|
reduce_motion_mode: reduce_motion,
|
||||||
|
..Default::default()
|
||||||
|
}));
|
||||||
|
|
||||||
|
let card = Card::new(Deck::Deck1, Suit::Spades, Rank::Ace);
|
||||||
|
let entity = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((
|
||||||
|
CardEntity { card: card.clone() },
|
||||||
|
Sprite::default(),
|
||||||
|
Transform::from_xyz(-100.0, 40.0, 1.0),
|
||||||
|
))
|
||||||
|
.id();
|
||||||
|
let mut index = CardEntityIndex::default();
|
||||||
|
index.0.insert(card.clone(), entity);
|
||||||
|
app.insert_resource(index);
|
||||||
|
app.insert_resource(LayoutResource(crate::layout::compute_layout(
|
||||||
|
Vec2::new(1280.0, 720.0),
|
||||||
|
0.0,
|
||||||
|
0.0,
|
||||||
|
true,
|
||||||
|
)));
|
||||||
|
app.update();
|
||||||
|
(app, card)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fire_hint(app: &mut App, card: Card) {
|
||||||
|
use solitaire_core::Tableau;
|
||||||
|
app.world_mut().write_message(HintVisualEvent {
|
||||||
|
source_card: card,
|
||||||
|
dest_pile: KlondikePile::Tableau(Tableau::Tableau2),
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ghost_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&HintGhost>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_event_spawns_one_ghost_at_the_source_card() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
|
||||||
|
assert_eq!(ghost_count(&mut app), 1, "hint must spawn one ghost");
|
||||||
|
let transform = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Transform, With<HintGhost>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("ghost transform");
|
||||||
|
// The tick system may have advanced the ghost by one real-time
|
||||||
|
// frame already; assert it is still essentially at the source.
|
||||||
|
assert!(
|
||||||
|
transform
|
||||||
|
.translation
|
||||||
|
.truncate()
|
||||||
|
.distance(Vec2::new(-100.0, 40.0))
|
||||||
|
< 5.0,
|
||||||
|
"ghost must start at the hinted card, got {:?}",
|
||||||
|
transform.translation
|
||||||
|
);
|
||||||
|
assert_eq!(transform.translation.z, HINT_GHOST_Z);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn hint_ghost_suppressed_under_reduce_motion() {
|
||||||
|
let (mut app, card) = ghost_app(true);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
0,
|
||||||
|
"no ghost may spawn under reduce-motion; the static highlight is the whole story"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn state_change_despawns_the_ghost() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
assert_eq!(ghost_count(&mut app), 1);
|
||||||
|
|
||||||
|
app.world_mut().write_message(StateChangedEvent);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
0,
|
||||||
|
"a board change makes the previewed move stale — the ghost must vanish"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn second_hint_replaces_the_first_ghost() {
|
||||||
|
let (mut app, card) = ghost_app(false);
|
||||||
|
fire_hint(&mut app, card.clone());
|
||||||
|
fire_hint(&mut app, card);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
ghost_count(&mut app),
|
||||||
|
1,
|
||||||
|
"re-hinting must replace the in-flight ghost, not stack a second"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ pub struct NewGameRequestWriters;
|
|||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
pub struct UndoRequestWriters;
|
pub struct UndoRequestWriters;
|
||||||
|
|
||||||
|
/// Self-ambiguous set for writers of `DrawRequestEvent` — same rationale as
|
||||||
|
/// [`NewGameRequestWriters`]: consumers drain the queue, append order is
|
||||||
|
/// meaningless (#143). Members: keyboard `D`/`Space`, stock click, touch
|
||||||
|
/// stock tap (input_plugin), and the touch action bar's Draw button
|
||||||
|
/// (hud_plugin, Phase F).
|
||||||
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct DrawRequestWriters;
|
||||||
|
|
||||||
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
/// Self-ambiguous set for writers of `InfoToastEvent` — toasts queue in
|
||||||
/// arrival order and any same-frame order is fine (#143).
|
/// arrival order and any same-frame order is fine (#143).
|
||||||
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
#[derive(SystemSet, Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
|||||||
+1136
-307
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,169 @@
|
|||||||
|
//! Single source of truth for the desktop keyboard bindings (Phase J).
|
||||||
|
//!
|
||||||
|
//! Two static hotkey tables had already diverged (onboarding's slide
|
||||||
|
//! and Help's controls reference); every future drift multiplies. This
|
||||||
|
//! module owns THE table: the onboarding slide renders the
|
||||||
|
//! [`HotkeyRow::essential`] subset, the hold-`/` cheat sheet
|
||||||
|
//! ([`crate::cheat_sheet_plugin`]) renders everything.
|
||||||
|
//!
|
||||||
|
//! The table is hand-maintained but **pinned by test** against the
|
||||||
|
//! handlers that actually consume each key — adding a binding without
|
||||||
|
//! updating this table (or vice versa) is designed to fail review, not
|
||||||
|
//! runtime. A registry generated from the input systems themselves is
|
||||||
|
//! the eventual ideal; this is the honest 90 % at 1 % of the cost.
|
||||||
|
|
||||||
|
/// One row of the hotkey table.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct HotkeyRow {
|
||||||
|
/// Display form of the key(s), e.g. `"D / Space"`.
|
||||||
|
pub keys: &'static str,
|
||||||
|
/// One-line action description.
|
||||||
|
pub description: &'static str,
|
||||||
|
/// `true` for the beginner-relevant subset the onboarding slide
|
||||||
|
/// shows; the cheat sheet always shows every row.
|
||||||
|
pub essential: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every desktop keyboard binding, in teaching order.
|
||||||
|
pub const HOTKEYS: &[HotkeyRow] = &[
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "D / Space",
|
||||||
|
description: "Draw from stock",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "U",
|
||||||
|
description: "Undo last move (hold to repeat)",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "H",
|
||||||
|
description: "Hint (repeat to cycle alternatives)",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "Tab → Enter",
|
||||||
|
description: "Pick a card; arrows pick where; Enter to drop",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "N",
|
||||||
|
description: "New Classic game",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "M",
|
||||||
|
description: "Open Home (then 1–6 to pick a mode)",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "Esc",
|
||||||
|
description: "Pause / resume; close the top dialog",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "F1",
|
||||||
|
description: "Help / controls",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "S",
|
||||||
|
description: "Stats & progression",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "A",
|
||||||
|
description: "Achievements",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "O",
|
||||||
|
description: "Settings",
|
||||||
|
essential: true,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "P",
|
||||||
|
description: "Profile",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "L",
|
||||||
|
description: "Leaderboard",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "C",
|
||||||
|
description: "Daily Challenge",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "Z",
|
||||||
|
description: "Zen mode",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "X",
|
||||||
|
description: "Challenge mode",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "T",
|
||||||
|
description: "Time Attack",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "G",
|
||||||
|
description: "Give up the current deal",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "[ / ]",
|
||||||
|
description: "Volume down / up",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "F11",
|
||||||
|
description: "Toggle fullscreen",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
HotkeyRow {
|
||||||
|
keys: "/ (hold)",
|
||||||
|
description: "This cheat sheet",
|
||||||
|
essential: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_is_populated_and_well_formed() {
|
||||||
|
assert!(!HOTKEYS.is_empty());
|
||||||
|
for row in HOTKEYS {
|
||||||
|
assert!(!row.keys.trim().is_empty(), "empty keys cell");
|
||||||
|
assert!(
|
||||||
|
!row.description.trim().is_empty(),
|
||||||
|
"empty description for {}",
|
||||||
|
row.keys
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn essential_subset_is_a_teachable_size() {
|
||||||
|
let essentials = HOTKEYS.iter().filter(|r| r.essential).count();
|
||||||
|
assert!(
|
||||||
|
(6..=12).contains(&essentials),
|
||||||
|
"the onboarding slide wants a skimmable essential set, got {essentials}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_cells_are_unique() {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
for row in HOTKEYS {
|
||||||
|
assert!(seen.insert(row.keys), "duplicate hotkey row: {}", row.keys);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,86 @@ pub(super) fn handle_undo_button(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Seconds the Undo button must be continuously held before hold-to-repeat
|
||||||
|
/// kicks in. Long enough that a normal tap (press + release inside one or
|
||||||
|
/// two frames) never triggers a second undo.
|
||||||
|
const UNDO_HOLD_INITIAL_DELAY_SECS: f32 = 0.45;
|
||||||
|
|
||||||
|
/// Interval between repeated undos while the hold continues. ~5.5 undos/s —
|
||||||
|
/// fast enough to unwind a long line, slow enough to release in time when
|
||||||
|
/// the board reaches the state the player wants.
|
||||||
|
const UNDO_HOLD_REPEAT_INTERVAL_SECS: f32 = 0.18;
|
||||||
|
|
||||||
|
/// Hold-to-repeat undo (Phase F): while the Undo button stays pressed,
|
||||||
|
/// fire additional [`UndoRequestEvent`]s after an initial delay, one per
|
||||||
|
/// repeat interval. The plain tap path stays in [`handle_undo_button`]
|
||||||
|
/// (its `Changed<Interaction>` filter fires exactly once per press);
|
||||||
|
/// this system only adds events once the hold outlives the delay, so a
|
||||||
|
/// tap never double-undoes.
|
||||||
|
///
|
||||||
|
/// Each repeat goes through the normal request queue — the scoring
|
||||||
|
/// penalty and No-Undo-mode gating in the consumer apply to every step.
|
||||||
|
pub(super) fn repeat_undo_on_hold(
|
||||||
|
time: Res<Time>,
|
||||||
|
buttons: Query<&Interaction, With<UndoButton>>,
|
||||||
|
mut state: ResMut<UndoHoldState>,
|
||||||
|
mut undo: MessageWriter<UndoRequestEvent>,
|
||||||
|
) {
|
||||||
|
let held = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
if !held {
|
||||||
|
if state.held_secs != 0.0 {
|
||||||
|
state.held_secs = 0.0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let before = state.held_secs;
|
||||||
|
state.held_secs += time.delta_secs();
|
||||||
|
if undo_hold_crossed_fire_boundary(before, state.held_secs) {
|
||||||
|
undo.write(UndoRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `true` when a hold that lasted `before` seconds at the previous frame
|
||||||
|
/// and `now` seconds this frame should fire a repeat undo: once when the
|
||||||
|
/// hold first outlives the initial delay, then once per repeat-interval
|
||||||
|
/// boundary. Pure so the timing contract is unit-testable without
|
||||||
|
/// fighting `Time`.
|
||||||
|
pub(super) fn undo_hold_crossed_fire_boundary(before: f32, now: f32) -> bool {
|
||||||
|
if now < UNDO_HOLD_INITIAL_DELAY_SECS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if before < UNDO_HOLD_INITIAL_DELAY_SECS {
|
||||||
|
// Crossed the initial-delay threshold this frame — first repeat.
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Fire once each time the hold crosses another repeat-interval boundary.
|
||||||
|
let intervals_before =
|
||||||
|
((before - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
|
||||||
|
let intervals_now =
|
||||||
|
((now - UNDO_HOLD_INITIAL_DELAY_SECS) / UNDO_HOLD_REPEAT_INTERVAL_SECS).floor();
|
||||||
|
intervals_now > intervals_before
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Click on the touch action bar's Draw button — same
|
||||||
|
/// [`DrawRequestEvent`] the stock-pile tap writes, so the consumer's
|
||||||
|
/// rules (recycle, won-game rejection) apply identically. Skipped while
|
||||||
|
/// paused, mirroring `handle_stock_click`'s guard.
|
||||||
|
pub(super) fn handle_draw_button(
|
||||||
|
interaction_query: Query<&Interaction, (With<DrawButton>, Changed<Interaction>)>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
mut draw: MessageWriter<DrawRequestEvent>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for interaction in &interaction_query {
|
||||||
|
if *interaction == Interaction::Pressed {
|
||||||
|
draw.write(DrawRequestEvent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub(super) fn handle_pause_button(
|
pub(super) fn handle_pause_button(
|
||||||
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
interaction_query: Query<&Interaction, (With<PauseButton>, Changed<Interaction>)>,
|
||||||
mut pause: MessageWriter<PauseRequestEvent>,
|
mut pause: MessageWriter<PauseRequestEvent>,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ struct AvatarResource(Option<bevy::prelude::Handle<bevy::prelude::Image>>);
|
|||||||
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
|
||||||
use crate::daily_challenge_plugin::DailyChallengeResource;
|
use crate::daily_challenge_plugin::DailyChallengeResource;
|
||||||
use crate::events::{
|
use crate::events::{
|
||||||
HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
DrawRequestEvent, HelpRequestEvent, InfoToastEvent, NewGameRequestEvent, PauseRequestEvent,
|
||||||
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
StartChallengeRequestEvent, StartDailyChallengeRequestEvent, StartTimeAttackRequestEvent,
|
||||||
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
StartZenRequestEvent, ToggleAchievementsRequestEvent, ToggleHomeRequestEvent,
|
||||||
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
ToggleLeaderboardRequestEvent, ToggleProfileRequestEvent, ToggleSettingsRequestEvent,
|
||||||
@@ -313,6 +313,18 @@ pub struct UndoButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct PauseButton;
|
pub struct PauseButton;
|
||||||
|
|
||||||
|
/// Accumulated hold time on the Undo button, driving hold-to-repeat undo
|
||||||
|
/// (Phase F). `handle_undo_button` owns the instant first undo on press;
|
||||||
|
/// `repeat_undo_on_hold` starts firing additional [`UndoRequestEvent`]s
|
||||||
|
/// once the hold passes `UNDO_HOLD_INITIAL_DELAY_SECS` and then every
|
||||||
|
/// `UNDO_HOLD_REPEAT_INTERVAL_SECS`. Each repeated undo goes through the
|
||||||
|
/// normal request path, so the existing scoring penalty applies per step.
|
||||||
|
#[derive(Resource, Debug, Default)]
|
||||||
|
pub struct UndoHoldState {
|
||||||
|
/// Seconds the Undo button has been continuously held.
|
||||||
|
held_secs: f32,
|
||||||
|
}
|
||||||
|
|
||||||
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
/// Marker on the "Help" action button. Click fires [`HelpRequestEvent`],
|
||||||
/// mirroring the `F1` keyboard accelerator.
|
/// mirroring the `F1` keyboard accelerator.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
@@ -323,31 +335,27 @@ pub struct HelpButton;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
pub struct HintButton;
|
pub struct HintButton;
|
||||||
|
|
||||||
|
/// Marker on the touch action bar's "Draw" button (Phase F). Click fires
|
||||||
|
/// [`DrawRequestEvent`] — same queue as tapping the stock pile — so the
|
||||||
|
/// draw action is within thumb reach without stretching to the top of a
|
||||||
|
/// tall folded screen. Touch layout only; desktop draws via stock
|
||||||
|
/// click / `D` / `Space`.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct DrawButton;
|
||||||
|
|
||||||
/// Android HUD label for the Hint button — shared with the help screen's
|
/// Android HUD label for the Hint button — shared with the help screen's
|
||||||
/// controls reference so both always agree.
|
/// controls reference so both always agree.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
pub(crate) const ANDROID_HINT_LABEL: &str = "Hint";
|
||||||
|
|
||||||
|
/// Hint label used by the touch action bar. Aliases [`ANDROID_HINT_LABEL`]
|
||||||
|
/// on Android so the bar and the help screen's controls reference can
|
||||||
|
/// never drift; the non-Android value only exists so the touch spawn
|
||||||
|
/// path stays host-compilable for tests.
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
const TOUCH_HINT_LABEL: &str = ANDROID_HINT_LABEL;
|
||||||
"Menu",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
ANDROID_HINT_LABEL,
|
|
||||||
"Mode",
|
|
||||||
"New",
|
|
||||||
];
|
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
const ACTION_BAR_LABELS: [&str; 7] = [
|
const TOUCH_HINT_LABEL: &str = "Hint";
|
||||||
"Menu \u{2193}",
|
|
||||||
"Undo",
|
|
||||||
"Pause",
|
|
||||||
"Help",
|
|
||||||
"Hint",
|
|
||||||
"Modes \u{2193}",
|
|
||||||
"New Game",
|
|
||||||
];
|
|
||||||
#[cfg(target_os = "android")]
|
#[cfg(target_os = "android")]
|
||||||
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
const ACTION_BAR_COLUMN_GAP: Val = Val::Px(4.0);
|
||||||
#[cfg(not(target_os = "android"))]
|
#[cfg(not(target_os = "android"))]
|
||||||
@@ -455,6 +463,7 @@ impl Plugin for HudPlugin {
|
|||||||
// idempotent.
|
// idempotent.
|
||||||
app.add_message::<NewGameRequestEvent>()
|
app.add_message::<NewGameRequestEvent>()
|
||||||
.add_message::<UndoRequestEvent>()
|
.add_message::<UndoRequestEvent>()
|
||||||
|
.add_message::<DrawRequestEvent>()
|
||||||
.add_message::<PauseRequestEvent>()
|
.add_message::<PauseRequestEvent>()
|
||||||
.add_message::<HelpRequestEvent>()
|
.add_message::<HelpRequestEvent>()
|
||||||
.add_message::<StartZenRequestEvent>()
|
.add_message::<StartZenRequestEvent>()
|
||||||
@@ -471,6 +480,7 @@ impl Plugin for HudPlugin {
|
|||||||
.init_resource::<PreviousScore>()
|
.init_resource::<PreviousScore>()
|
||||||
.init_resource::<HudActionFade>()
|
.init_resource::<HudActionFade>()
|
||||||
.init_resource::<HudVisibility>()
|
.init_resource::<HudVisibility>()
|
||||||
|
.init_resource::<UndoHoldState>()
|
||||||
// Escape-close handlers for popovers read this; init defensively
|
// Escape-close handlers for popovers read this; init defensively
|
||||||
// so HudPlugin works under MinimalPlugins in tests.
|
// so HudPlugin works under MinimalPlugins in tests.
|
||||||
.init_resource::<ButtonInput<KeyCode>>()
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
@@ -560,6 +570,14 @@ impl Plugin for HudPlugin {
|
|||||||
.in_set(crate::game_plugin::UndoRequestWriters)
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
.before(GameMutation),
|
.before(GameMutation),
|
||||||
|
repeat_undo_on_hold
|
||||||
|
.in_set(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::UndoRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
|
handle_draw_button
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.before(GameMutation),
|
||||||
handle_pause_button,
|
handle_pause_button,
|
||||||
handle_help_button,
|
handle_help_button,
|
||||||
handle_hint_button
|
handle_hint_button
|
||||||
|
|||||||
@@ -400,83 +400,152 @@ pub(super) fn spawn_action_buttons(
|
|||||||
HudActionBar,
|
HudActionBar,
|
||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
// so Tab cycles the action bar in visual reading order.
|
spawn_touch_action_bar(row, &font);
|
||||||
// Undo and Pause are the primary gameplay actions — full brightness.
|
} else {
|
||||||
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
spawn_desktop_action_bar(row, &font);
|
||||||
spawn_action_button(
|
}
|
||||||
row,
|
|
||||||
MenuButton,
|
|
||||||
ACTION_BAR_LABELS[0],
|
|
||||||
None,
|
|
||||||
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
|
||||||
&font,
|
|
||||||
0,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
UndoButton,
|
|
||||||
ACTION_BAR_LABELS[1],
|
|
||||||
Some("U"),
|
|
||||||
"Take back your last move. Costs points and blocks No Undo.",
|
|
||||||
&font,
|
|
||||||
1,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
PauseButton,
|
|
||||||
ACTION_BAR_LABELS[2],
|
|
||||||
Some("Esc"),
|
|
||||||
"Pause the game and freeze the timer.",
|
|
||||||
&font,
|
|
||||||
2,
|
|
||||||
TEXT_PRIMARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
HelpButton,
|
|
||||||
ACTION_BAR_LABELS[3],
|
|
||||||
Some("F1"),
|
|
||||||
"Show controls, rules, and keyboard shortcuts.",
|
|
||||||
&font,
|
|
||||||
3,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
HintButton,
|
|
||||||
ACTION_BAR_LABELS[4],
|
|
||||||
Some("H"),
|
|
||||||
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
|
||||||
&font,
|
|
||||||
4,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
ModesButton,
|
|
||||||
ACTION_BAR_LABELS[5],
|
|
||||||
None,
|
|
||||||
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
|
||||||
&font,
|
|
||||||
5,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
spawn_action_button(
|
|
||||||
row,
|
|
||||||
NewGameButton,
|
|
||||||
ACTION_BAR_LABELS[6],
|
|
||||||
Some("N"),
|
|
||||||
"Start a fresh deal. Confirms first if a game is in progress.",
|
|
||||||
&font,
|
|
||||||
6,
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
/// is touch-only). All seven actions, uniform sizing.
|
||||||
|
pub(super) fn spawn_desktop_action_bar(row: &mut ChildSpawnerCommands, font: &TextFont) {
|
||||||
|
// The trailing `order` argument feeds `Focusable { group: Hud, order }`
|
||||||
|
// so Tab cycles the action bar in visual reading order.
|
||||||
|
// Undo and Pause are the primary gameplay actions — full brightness.
|
||||||
|
// Menu, Help, Hint, Modes, New are navigation/utility — dimmed.
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
MenuButton,
|
||||||
|
"Menu \u{2193}",
|
||||||
|
None,
|
||||||
|
"Open Stats, Achievements, Profile, Settings, or Leaderboard.",
|
||||||
|
font,
|
||||||
|
0,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
UndoButton,
|
||||||
|
"Undo",
|
||||||
|
Some("U"),
|
||||||
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo.",
|
||||||
|
font,
|
||||||
|
1,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
PauseButton,
|
||||||
|
"Pause",
|
||||||
|
Some("Esc"),
|
||||||
|
"Pause the game and freeze the timer.",
|
||||||
|
font,
|
||||||
|
2,
|
||||||
|
TEXT_PRIMARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HelpButton,
|
||||||
|
"Help",
|
||||||
|
Some("F1"),
|
||||||
|
"Show controls, rules, and keyboard shortcuts.",
|
||||||
|
font,
|
||||||
|
3,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
HintButton,
|
||||||
|
"Hint",
|
||||||
|
Some("H"),
|
||||||
|
"Highlight a suggested move. Cycles through alternatives on repeat taps.",
|
||||||
|
font,
|
||||||
|
4,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
ModesButton,
|
||||||
|
"Modes \u{2193}",
|
||||||
|
None,
|
||||||
|
"Switch modes: Classic, Daily, Zen, Challenge, Time Attack.",
|
||||||
|
font,
|
||||||
|
5,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
spawn_action_button(
|
||||||
|
row,
|
||||||
|
NewGameButton,
|
||||||
|
"New Game",
|
||||||
|
Some("N"),
|
||||||
|
"Start a fresh deal. Confirms first if a game is in progress.",
|
||||||
|
font,
|
||||||
|
6,
|
||||||
|
TEXT_SECONDARY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Spawns a single action button as a child of `row`. Each button shares
|
/// Spawns a single action button as a child of `row`. Each button shares
|
||||||
/// the same node geometry, idle colour, and `ActionButton` marker so
|
/// the same node geometry, idle colour, and `ActionButton` marker so
|
||||||
/// `paint_action_buttons` can recolour all of them with one query.
|
/// `paint_action_buttons` can recolour all of them with one query.
|
||||||
@@ -510,13 +579,69 @@ pub(super) fn spawn_action_button<M: Component>(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let (pad, min_w, min_h) = action_button_metrics();
|
||||||
|
spawn_action_button_sized(
|
||||||
|
row, marker, label, hotkey, tooltip, font, order, text_color, pad, min_w, min_h,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enlarged variant for the touch bar's core gameplay trio (Phase F):
|
||||||
|
/// bigger padding / minimum target and a scaled-up label so Undo, Draw,
|
||||||
|
/// and Hint read (and hit) as the primary actions. Hotkey chips are
|
||||||
|
/// 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)]
|
||||||
|
fn spawn_action_button_sized<M: Component>(
|
||||||
|
row: &mut ChildSpawnerCommands,
|
||||||
|
marker: M,
|
||||||
|
label: &str,
|
||||||
|
hotkey: Option<&'static str>,
|
||||||
|
tooltip: &'static str,
|
||||||
|
font: &TextFont,
|
||||||
|
order: i32,
|
||||||
|
text_color: Color,
|
||||||
|
pad: UiRect,
|
||||||
|
min_w: Val,
|
||||||
|
min_h: Val,
|
||||||
|
) {
|
||||||
let hotkey_font = TextFont {
|
let hotkey_font = TextFont {
|
||||||
font: font.font.clone(),
|
font: font.font.clone(),
|
||||||
font_size: TYPE_CAPTION,
|
font_size: TYPE_CAPTION,
|
||||||
..default()
|
..default()
|
||||||
};
|
};
|
||||||
let (pad, min_w, min_h) = action_button_metrics();
|
|
||||||
|
|
||||||
row.spawn((
|
row.spawn((
|
||||||
marker,
|
marker,
|
||||||
ActionButton,
|
ActionButton,
|
||||||
|
|||||||
@@ -670,7 +670,7 @@ fn hud_elements_carry_expected_tooltip_strings() {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tooltip_for::<UndoButton>(&mut app),
|
tooltip_for::<UndoButton>(&mut app),
|
||||||
"Take back your last move. Costs points and blocks No Undo."
|
"Take back your last move. Hold to keep undoing. Costs points and blocks No Undo."
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
tooltip_for::<PauseButton>(&mut app),
|
tooltip_for::<PauseButton>(&mut app),
|
||||||
@@ -899,3 +899,157 @@ fn hud_focus_only_engages_when_button_hovered() {
|
|||||||
"Hud-engaged Tab must focus a Hud-grouped entity"
|
"Hud-engaged Tab must focus a Hud-grouped entity"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase F: touch action bar + hold-to-repeat undo
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Bare app (no `HudPlugin`) so bar-content assertions count only the
|
||||||
|
/// buttons the tested spawn function creates — `headless_app` would
|
||||||
|
/// pre-spawn the platform-default bar and pollute the counts.
|
||||||
|
fn bar_only_app(touch: bool) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins);
|
||||||
|
app.update();
|
||||||
|
let font = TextFont::default();
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
commands.spawn(Node::default()).with_children(|row| {
|
||||||
|
if touch {
|
||||||
|
spawn_touch_action_bar(row, &font);
|
||||||
|
} else {
|
||||||
|
spawn_desktop_action_bar(row, &font);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn count_buttons<C: Component>(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query_filtered::<(), With<C>>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn touch_action_bar_has_thumb_trio_plus_menu_and_pause() {
|
||||||
|
let mut app = bar_only_app(true);
|
||||||
|
|
||||||
|
assert_eq!(count_buttons::<UndoButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<DrawButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<HintButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<MenuButton>(&mut app), 1);
|
||||||
|
assert_eq!(count_buttons::<PauseButton>(&mut app), 1);
|
||||||
|
|
||||||
|
// Utility actions live elsewhere on touch: Help in Menu → System,
|
||||||
|
// modes on the Home screen, New Game on Home's hero button.
|
||||||
|
assert_eq!(count_buttons::<HelpButton>(&mut app), 0);
|
||||||
|
assert_eq!(count_buttons::<ModesButton>(&mut app), 0);
|
||||||
|
assert_eq!(count_buttons::<NewGameButton>(&mut app), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn desktop_action_bar_keeps_all_seven_and_no_draw() {
|
||||||
|
let mut app = bar_only_app(false);
|
||||||
|
|
||||||
|
for (count, name) in [
|
||||||
|
(count_buttons::<MenuButton>(&mut app), "Menu"),
|
||||||
|
(count_buttons::<UndoButton>(&mut app), "Undo"),
|
||||||
|
(count_buttons::<PauseButton>(&mut app), "Pause"),
|
||||||
|
(count_buttons::<HelpButton>(&mut app), "Help"),
|
||||||
|
(count_buttons::<HintButton>(&mut app), "Hint"),
|
||||||
|
(count_buttons::<ModesButton>(&mut app), "Modes"),
|
||||||
|
(count_buttons::<NewGameButton>(&mut app), "New Game"),
|
||||||
|
] {
|
||||||
|
assert_eq!(count, 1, "desktop bar must keep its {name} button");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
count_buttons::<DrawButton>(&mut app),
|
||||||
|
0,
|
||||||
|
"Draw is touch-only (decision 5); desktop draws via stock click / D / Space"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_button_press_fires_draw_request() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<DrawRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
app.world_mut().spawn((DrawButton, Interaction::Pressed));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<DrawRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(
|
||||||
|
cursor.read(events).count(),
|
||||||
|
1,
|
||||||
|
"pressing the Draw button must fire exactly one DrawRequestEvent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn draw_button_press_is_noop_while_paused() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(PausedResource(true));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<Messages<DrawRequestEvent>>()
|
||||||
|
.clear();
|
||||||
|
|
||||||
|
app.world_mut().spawn((DrawButton, Interaction::Pressed));
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let events = app.world().resource::<Messages<DrawRequestEvent>>();
|
||||||
|
let mut cursor = events.get_cursor();
|
||||||
|
assert_eq!(
|
||||||
|
cursor.read(events).count(),
|
||||||
|
0,
|
||||||
|
"the Draw button must be inert while paused (mirrors the stock-click guard)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The timing contract for hold-to-repeat undo, exercised through the
|
||||||
|
/// pure boundary function so no wall-clock is involved. Delay = 0.45 s,
|
||||||
|
/// interval = 0.18 s → boundaries at 0.45, 0.63, 0.81, …
|
||||||
|
#[test]
|
||||||
|
fn undo_hold_boundary_math() {
|
||||||
|
// A tap never repeats.
|
||||||
|
assert!(!undo_hold_crossed_fire_boundary(0.0, 0.05));
|
||||||
|
assert!(!undo_hold_crossed_fire_boundary(0.2, 0.44));
|
||||||
|
// Crossing the initial delay fires the first repeat.
|
||||||
|
assert!(undo_hold_crossed_fire_boundary(0.44, 0.46));
|
||||||
|
// Inside the first repeat interval: quiet.
|
||||||
|
assert!(!undo_hold_crossed_fire_boundary(0.46, 0.62));
|
||||||
|
// Each interval boundary fires exactly once.
|
||||||
|
assert!(undo_hold_crossed_fire_boundary(0.62, 0.64));
|
||||||
|
assert!(!undo_hold_crossed_fire_boundary(0.64, 0.80));
|
||||||
|
assert!(undo_hold_crossed_fire_boundary(0.80, 0.82));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_hold_state_accumulates_and_resets_on_release() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let button = app
|
||||||
|
.world_mut()
|
||||||
|
.spawn((UndoButton, Interaction::Pressed))
|
||||||
|
.id();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<UndoHoldState>().held_secs > 0.0,
|
||||||
|
"held time must accumulate while the Undo button stays pressed"
|
||||||
|
);
|
||||||
|
|
||||||
|
app.world_mut().entity_mut(button).insert(Interaction::None);
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
app.world().resource::<UndoHoldState>().held_secs,
|
||||||
|
0.0,
|
||||||
|
"releasing the Undo button must reset the hold state"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -554,11 +554,11 @@ pub(super) fn action_bar_font_size(window_width: f32) -> f32 {
|
|||||||
|
|
||||||
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
pub(super) fn action_button_metrics() -> (UiRect, Val, Val) {
|
||||||
if USE_TOUCH_UI_LAYOUT {
|
if USE_TOUCH_UI_LAYOUT {
|
||||||
// Tight 3 px horizontal padding (down from 4) trims 14 px off the row
|
// Tight 3 px horizontal padding keeps the compact buttons narrow;
|
||||||
// total across 7 buttons, and a 44 px min_width (down from 52) lets the
|
// Phase F trimmed the touch bar to 5 buttons (2 compact + 3
|
||||||
// shortest labels ("New", "Help") shrink to their text rather than
|
// primary), so width pressure is lower than the old 7-button row.
|
||||||
// padding the row out past the 900 logical-px viewport. min_height
|
// min_height stays at 44 px to preserve the comfortable touch
|
||||||
// stays at 44 px to preserve the comfortable touch target.
|
// target on the compact buttons.
|
||||||
(
|
(
|
||||||
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
UiRect::axes(Val::Px(3.0), Val::Px(4.0)),
|
||||||
Val::Px(44.0),
|
Val::Px(44.0),
|
||||||
@@ -573,6 +573,20 @@ 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,
|
||||||
|
|||||||
@@ -136,11 +136,22 @@ impl Plugin for InputPlugin {
|
|||||||
.add_systems(
|
.add_systems(
|
||||||
Update,
|
Update,
|
||||||
(
|
(
|
||||||
handle_keyboard_core,
|
// The three `DrawRequestEvent` writers join the
|
||||||
|
// self-ambiguous `DrawRequestWriters` set so the HUD's
|
||||||
|
// touch Draw button (hud_plugin, Phase F) can write the
|
||||||
|
// same queue without tripping the ambiguity gate. The
|
||||||
|
// `.chain()` still orders them relative to each other.
|
||||||
|
handle_keyboard_core
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
handle_keyboard_hint,
|
handle_keyboard_hint,
|
||||||
handle_keyboard_forfeit,
|
handle_keyboard_forfeit,
|
||||||
handle_stock_click,
|
handle_stock_click
|
||||||
handle_touch_stock_tap,
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
|
handle_touch_stock_tap
|
||||||
|
.in_set(crate::game_plugin::DrawRequestWriters)
|
||||||
|
.ambiguous_with(crate::game_plugin::DrawRequestWriters),
|
||||||
handle_double_click,
|
handle_double_click,
|
||||||
// Mouse drag pipeline.
|
// Mouse drag pipeline.
|
||||||
start_drag,
|
start_drag,
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub mod avatar_plugin;
|
|||||||
pub mod card_animation;
|
pub mod card_animation;
|
||||||
pub mod card_plugin;
|
pub mod card_plugin;
|
||||||
pub mod challenge_plugin;
|
pub mod challenge_plugin;
|
||||||
|
pub mod cheat_sheet_plugin;
|
||||||
|
pub mod contextual_tips_plugin;
|
||||||
pub mod core_game_plugin;
|
pub mod core_game_plugin;
|
||||||
pub mod cursor_plugin;
|
pub mod cursor_plugin;
|
||||||
pub mod daily_challenge_plugin;
|
pub mod daily_challenge_plugin;
|
||||||
@@ -26,6 +28,7 @@ pub mod font_plugin;
|
|||||||
pub mod game_plugin;
|
pub mod game_plugin;
|
||||||
pub mod help_plugin;
|
pub mod help_plugin;
|
||||||
pub mod home_plugin;
|
pub mod home_plugin;
|
||||||
|
pub mod hotkeys;
|
||||||
pub mod hud_plugin;
|
pub mod hud_plugin;
|
||||||
pub mod input_plugin;
|
pub mod input_plugin;
|
||||||
pub mod layout;
|
pub mod layout;
|
||||||
@@ -64,6 +67,7 @@ pub mod ui_modal;
|
|||||||
pub mod ui_theme;
|
pub mod ui_theme;
|
||||||
pub mod ui_tooltip;
|
pub mod ui_tooltip;
|
||||||
pub mod weekly_goals_plugin;
|
pub mod weekly_goals_plugin;
|
||||||
|
pub mod whats_new_plugin;
|
||||||
pub mod win_summary_plugin;
|
pub mod win_summary_plugin;
|
||||||
pub mod you_hub_plugin;
|
pub mod you_hub_plugin;
|
||||||
|
|
||||||
@@ -93,6 +97,8 @@ pub use card_plugin::{
|
|||||||
pub use challenge_plugin::{
|
pub use challenge_plugin::{
|
||||||
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
CHALLENGE_UNLOCK_LEVEL, ChallengeAdvancedEvent, ChallengePlugin, challenge_progress_label,
|
||||||
};
|
};
|
||||||
|
pub use cheat_sheet_plugin::{CheatSheetOverlay, CheatSheetPlugin};
|
||||||
|
pub use contextual_tips_plugin::ContextualTipsPlugin;
|
||||||
pub use core_game_plugin::CoreGamePlugin;
|
pub use core_game_plugin::CoreGamePlugin;
|
||||||
pub use cursor_plugin::CursorPlugin;
|
pub use cursor_plugin::CursorPlugin;
|
||||||
pub use daily_challenge_plugin::{
|
pub use daily_challenge_plugin::{
|
||||||
@@ -194,6 +200,7 @@ pub use ui_modal::{
|
|||||||
};
|
};
|
||||||
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
|
pub use ui_tooltip::{Tooltip, UiTooltipPlugin};
|
||||||
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
pub use weekly_goals_plugin::{WeeklyGoalCompletedEvent, WeeklyGoalsPlugin};
|
||||||
|
pub use whats_new_plugin::{WhatsNewPending, WhatsNewPlugin, WhatsNewScreen};
|
||||||
pub use win_summary_plugin::{
|
pub use win_summary_plugin::{
|
||||||
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
ScreenShakeResource, SessionAchievements, WinSummaryPending, WinSummaryPlugin, format_win_time,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -84,66 +84,11 @@ struct OnboardingSkipButton;
|
|||||||
pub struct OnboardingSlideIndex(pub u8);
|
pub struct OnboardingSlideIndex(pub u8);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Slide data — hotkey rows are taken verbatim from `help_plugin.rs` so the
|
// Slide data — the hotkey slide renders the essential subset of the
|
||||||
// two screens stay in sync without a shared abstraction.
|
// shared table in `crate::hotkeys` (Phase J unified the previously
|
||||||
|
// diverging copies here and in help_plugin).
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// A single `key — description` pair shown on slide 3.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
struct HotkeyRow {
|
|
||||||
keys: &'static str,
|
|
||||||
description: &'static str,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Most-used shortcuts from the `help_plugin` canonical list.
|
|
||||||
///
|
|
||||||
/// Updating the list in `help_plugin.rs` should be mirrored here. The
|
|
||||||
/// ARCHITECTURE.md decision log calls out that we copy values rather than
|
|
||||||
/// refactor the help plugin.
|
|
||||||
#[cfg(not(target_os = "android"))]
|
|
||||||
const HOTKEYS: &[HotkeyRow] = &[
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "D / Space",
|
|
||||||
description: "Draw from stock",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "U",
|
|
||||||
description: "Undo last move",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "Tab → Enter",
|
|
||||||
description: "Pick a card; arrows pick where; Enter to drop",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "N",
|
|
||||||
description: "New Classic game",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "M",
|
|
||||||
description: "Open Mode Launcher (then 1–5 to pick)",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "S",
|
|
||||||
description: "Stats & progression",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "A",
|
|
||||||
description: "Achievements",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "O",
|
|
||||||
description: "Settings",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "Esc",
|
|
||||||
description: "Pause / resume",
|
|
||||||
},
|
|
||||||
HotkeyRow {
|
|
||||||
keys: "F1",
|
|
||||||
description: "Help / controls",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -313,6 +258,10 @@ fn complete_onboarding(
|
|||||||
despawn_screen(commands, screens);
|
despawn_screen(commands, screens);
|
||||||
if let Some(s) = settings {
|
if let Some(s) = settings {
|
||||||
s.0.first_run_complete = true;
|
s.0.first_run_complete = true;
|
||||||
|
// A fresh install has nothing "new" to announce — stamp the
|
||||||
|
// running release so the What's-new card (Phase I) only ever
|
||||||
|
// fires after an actual upgrade.
|
||||||
|
s.0.last_seen_whats_new = crate::whats_new_plugin::current_release_version();
|
||||||
persist(path.map(|p| &p.0), &s.0);
|
persist(path.map(|p| &p.0), &s.0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -382,20 +331,27 @@ fn spawn_slide_welcome(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How-to-play body copy, phrased for the platform's input vocabulary —
|
||||||
|
/// a touch player never left-clicks (Phase H polish; spotted in the
|
||||||
|
/// emulator smoke of v0.44.0).
|
||||||
|
#[cfg(target_os = "android")]
|
||||||
|
const HOW_TO_PLAY_BODY: &str = "Drag any face-up card to move it between piles. \
|
||||||
|
You can drag a whole column at once by grabbing the topmost card \
|
||||||
|
you want to move. Double-tap a face-up card to send it to a \
|
||||||
|
foundation pile automatically (when the move is legal). \
|
||||||
|
Tap Hint in the bottom bar for a suggested move.";
|
||||||
|
#[cfg(not(target_os = "android"))]
|
||||||
|
const HOW_TO_PLAY_BODY: &str = "Left-click and drag any face-up card to move it between piles. \
|
||||||
|
You can drag a whole column at once by grabbing the topmost card \
|
||||||
|
you want to move. Double-click a face-up card to send it to a \
|
||||||
|
foundation pile automatically (when the move is legal). \
|
||||||
|
Right-click a card for a hint — valid destinations will highlight.";
|
||||||
|
|
||||||
/// Slide 2 — How to play.
|
/// Slide 2 — How to play.
|
||||||
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
fn spawn_slide_how_to_play(commands: &mut Commands, font_res: Option<&FontResource>) {
|
||||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
spawn_modal_header(card, "Drag cards to play", font_res);
|
spawn_modal_header(card, "Drag cards to play", font_res);
|
||||||
spawn_modal_body_text(
|
spawn_modal_body_text(card, HOW_TO_PLAY_BODY, TEXT_SECONDARY, font_res);
|
||||||
card,
|
|
||||||
"Left-click and drag any face-up card to move it between piles. \
|
|
||||||
You can drag a whole column at once by grabbing the topmost card \
|
|
||||||
you want to move. Double-click a face-up card to send it to a \
|
|
||||||
foundation pile automatically (when the move is legal). \
|
|
||||||
Right-click a card for a hint — valid destinations will highlight.",
|
|
||||||
TEXT_SECONDARY,
|
|
||||||
font_res,
|
|
||||||
);
|
|
||||||
spawn_modal_actions(card, |actions| {
|
spawn_modal_actions(card, |actions| {
|
||||||
spawn_modal_button(
|
spawn_modal_button(
|
||||||
actions,
|
actions,
|
||||||
@@ -435,8 +391,10 @@ fn spawn_slide_hotkeys(commands: &mut Commands, font_res: Option<&FontResource>)
|
|||||||
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
spawn_modal(commands, OnboardingScreen, Z_ONBOARDING, |card| {
|
||||||
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
spawn_modal_header(card, "Keyboard shortcuts", font_res);
|
||||||
|
|
||||||
// Vertical list of `key — description` rows, same chip style as HelpScreen.
|
// Vertical list of `key — description` rows, same chip style as
|
||||||
for row in HOTKEYS {
|
// HelpScreen. Essential subset only — the full table lives on
|
||||||
|
// the hold-`/` cheat sheet.
|
||||||
|
for row in crate::hotkeys::HOTKEYS.iter().filter(|r| r.essential) {
|
||||||
card.spawn(Node {
|
card.spawn(Node {
|
||||||
flex_direction: FlexDirection::Row,
|
flex_direction: FlexDirection::Row,
|
||||||
align_items: AlignItems::Center,
|
align_items: AlignItems::Center,
|
||||||
@@ -815,22 +773,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Hotkey list is non-empty (guards against accidental truncation)
|
// Hotkey slide renders a non-empty essential subset (the table's own
|
||||||
|
// integrity tests live in `crate::hotkeys`)
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn hotkey_list_is_non_empty() {
|
fn hotkey_slide_subset_is_non_empty() {
|
||||||
assert!(!HOTKEYS.is_empty(), "HOTKEYS must not be empty");
|
assert!(
|
||||||
}
|
crate::hotkeys::HOTKEYS.iter().any(|r| r.essential),
|
||||||
|
"the onboarding slide needs at least one essential hotkey"
|
||||||
#[test]
|
);
|
||||||
fn all_hotkey_rows_have_non_empty_fields() {
|
|
||||||
for row in HOTKEYS {
|
|
||||||
assert!(!row.keys.is_empty(), "hotkey key field must not be empty");
|
|
||||||
assert!(
|
|
||||||
!row.description.is_empty(),
|
|
||||||
"hotkey description must not be empty"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,9 +101,10 @@ impl Plugin for SafeAreaInsetsPlugin {
|
|||||||
fn apply_safe_area_anchors(
|
fn apply_safe_area_anchors(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
mut q: Query<(&SafeAreaAnchoredTop, &mut Node)>,
|
||||||
) {
|
) {
|
||||||
if !insets.is_changed() {
|
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
// Android's WindowInsets API returns physical pixels; Bevy UI's Val::Px
|
||||||
@@ -119,19 +120,31 @@ fn apply_safe_area_anchors(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let top_logical = raw_top.min(max_inset);
|
let top_logical = raw_top.min(max_inset);
|
||||||
|
// `bevy::ui::UiScale` (Phase K) multiplies every Val::Px at layout
|
||||||
|
// time. `base_top` is UI chrome and SHOULD scale with the rest of
|
||||||
|
// the interface, but the system-bar inset is physical reality —
|
||||||
|
// pre-divide it so the post-multiplication offset stays exact.
|
||||||
|
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||||
for (anchor, mut node) in &mut q {
|
for (anchor, mut node) in &mut q {
|
||||||
node.top = Val::Px(anchor.base_top + top_logical);
|
node.top = Val::Px(anchor.base_top + top_logical / ui);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The live UI scale, defensively clamped — a zero or negative scale
|
||||||
|
/// would flip or destroy the inset math.
|
||||||
|
fn effective_ui_scale(ui_scale: Option<&UiScale>) -> f32 {
|
||||||
|
ui_scale.map_or(1.0, |s| s.0.max(0.1))
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
/// Re-applies `base_bottom + insets.bottom / scale` to every entity carrying
|
||||||
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
/// [`SafeAreaAnchoredBottom`] whenever [`SafeAreaInsets`] changes.
|
||||||
fn apply_safe_area_bottom_anchors(
|
fn apply_safe_area_bottom_anchors(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
mut q: Query<(&SafeAreaAnchoredBottom, &mut Node)>,
|
||||||
) {
|
) {
|
||||||
if !insets.is_changed() {
|
if !insets.is_changed() && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
@@ -144,8 +157,12 @@ fn apply_safe_area_bottom_anchors(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let bottom_logical = raw_bottom.min(max_inset);
|
let bottom_logical = raw_bottom.min(max_inset);
|
||||||
|
// See `apply_safe_area_anchors`: the physical inset is pre-divided
|
||||||
|
// by the UI scale so a 90% setting can't sink the action bar into
|
||||||
|
// the gesture zone (and 130% doesn't over-inset it).
|
||||||
|
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||||
for (anchor, mut node) in &mut q {
|
for (anchor, mut node) in &mut q {
|
||||||
node.bottom = Val::Px(anchor.base_bottom + bottom_logical);
|
node.bottom = Val::Px(anchor.base_bottom + bottom_logical / ui);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,19 +180,23 @@ fn apply_safe_area_bottom_anchors(
|
|||||||
fn apply_safe_area_to_modal_scrims(
|
fn apply_safe_area_to_modal_scrims(
|
||||||
insets: Res<SafeAreaInsets>,
|
insets: Res<SafeAreaInsets>,
|
||||||
windows: Query<&Window>,
|
windows: Query<&Window>,
|
||||||
|
ui_scale: Option<Res<UiScale>>,
|
||||||
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
mut scrims: Query<&mut Node, With<ModalScrim>>,
|
||||||
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
new_scrims: Query<(), (With<ModalScrim>, Added<ModalScrim>)>,
|
||||||
) {
|
) {
|
||||||
let has_new = !new_scrims.is_empty();
|
let has_new = !new_scrims.is_empty();
|
||||||
if !insets.is_changed() && !has_new {
|
if !insets.is_changed() && !has_new && !ui_scale.as_ref().is_some_and(|s| s.is_changed()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
let scale = windows.iter().next().map_or(1.0, |w| w.scale_factor());
|
||||||
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
let window_height = windows.iter().next().map_or(800.0, |w| w.height());
|
||||||
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
// Clamp each inset to 25% of screen height so an unexpectedly large OS
|
||||||
// value can't push the modal card off the visible area entirely.
|
// value can't push the modal card off the visible area entirely.
|
||||||
let top_logical = (insets.top / scale).min(window_height * 0.25);
|
// Physical insets are pre-divided by the UI scale — see
|
||||||
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25);
|
// `apply_safe_area_anchors`.
|
||||||
|
let ui = effective_ui_scale(ui_scale.as_deref());
|
||||||
|
let top_logical = (insets.top / scale).min(window_height * 0.25) / ui;
|
||||||
|
let bottom_logical = (insets.bottom / scale).min(window_height * 0.25) / ui;
|
||||||
for mut node in &mut scrims {
|
for mut node in &mut scrims {
|
||||||
// Set both edges so the scrim's content box equals the usable area
|
// Set both edges so the scrim's content box equals the usable area
|
||||||
// between the status bar and the gesture/navigation bar. With
|
// between the status bar and the gesture/navigation bar. With
|
||||||
|
|||||||
@@ -363,6 +363,14 @@ pub(super) fn handle_settings_buttons(
|
|||||||
changed.write(SettingsChangedEvent(settings.0.clone()));
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
// Text refreshed by `update_touch_input_mode_text` next frame.
|
// Text refreshed by `update_touch_input_mode_text` next frame.
|
||||||
}
|
}
|
||||||
|
SettingsButton::CycleUiScale => {
|
||||||
|
settings.0.ui_scale = next_ui_scale(settings.0.ui_scale);
|
||||||
|
persist(&path, &settings.0);
|
||||||
|
changed.write(SettingsChangedEvent(settings.0.clone()));
|
||||||
|
// Text refreshed by `update_ui_scale_text`; the live
|
||||||
|
// `bevy::ui::UiScale` resource follows via
|
||||||
|
// `sync_ui_scale_resource` next frame.
|
||||||
|
}
|
||||||
SettingsButton::ToggleWinnableDealsOnly => {
|
SettingsButton::ToggleWinnableDealsOnly => {
|
||||||
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
settings.0.winnable_deals_only = !settings.0.winnable_deals_only;
|
||||||
persist(&path, &settings.0);
|
persist(&path, &settings.0);
|
||||||
|
|||||||
@@ -139,6 +139,10 @@ struct ReduceMotionText;
|
|||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TouchInputModeText;
|
struct TouchInputModeText;
|
||||||
|
|
||||||
|
/// Marks the `Text` node showing the current UI scale percentage.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct UiScaleText;
|
||||||
|
|
||||||
/// Marks the `Text` node showing the live tooltip-delay value.
|
/// Marks the `Text` node showing the live tooltip-delay value.
|
||||||
#[derive(Component, Debug)]
|
#[derive(Component, Debug)]
|
||||||
struct TooltipDelayText;
|
struct TooltipDelayText;
|
||||||
@@ -277,6 +281,10 @@ enum SettingsButton {
|
|||||||
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
|
||||||
/// a card/stack, second tap on a target pile moves it).
|
/// a card/stack, second tap on a target pile moves it).
|
||||||
ToggleTouchInputMode,
|
ToggleTouchInputMode,
|
||||||
|
/// Cycle [`Settings::ui_scale`] through 90 % → 100 % → 115 % →
|
||||||
|
/// 130 % → 90 %. Applied live via `bevy::ui::UiScale`; the table
|
||||||
|
/// stays window-fit (Phase K).
|
||||||
|
CycleUiScale,
|
||||||
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
/// Toggle the [`Settings::winnable_deals_only`] flag. When on, new
|
||||||
/// random Classic-mode deals are filtered through
|
/// random Classic-mode deals are filtered through
|
||||||
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
/// [`solitaire_core::game_state::GameState::solve_fresh_deal`] until one is provably
|
||||||
@@ -354,6 +362,7 @@ impl SettingsButton {
|
|||||||
SettingsButton::ToggleHighContrast => 61,
|
SettingsButton::ToggleHighContrast => 61,
|
||||||
SettingsButton::ToggleReduceMotion => 62,
|
SettingsButton::ToggleReduceMotion => 62,
|
||||||
SettingsButton::ToggleTouchInputMode => 63,
|
SettingsButton::ToggleTouchInputMode => 63,
|
||||||
|
SettingsButton::CycleUiScale => 64,
|
||||||
// Picker rows — every swatch in a row shares the row's
|
// Picker rows — every swatch in a row shares the row's
|
||||||
// priority so entity-index tiebreaking yields left → right.
|
// priority so entity-index tiebreaking yields left → right.
|
||||||
SettingsButton::SelectCardBack(_) => 70,
|
SettingsButton::SelectCardBack(_) => 70,
|
||||||
@@ -449,6 +458,11 @@ impl Plugin for SettingsPlugin {
|
|||||||
handle_volume_keys,
|
handle_volume_keys,
|
||||||
record_window_geometry_changes,
|
record_window_geometry_changes,
|
||||||
persist_window_geometry_after_debounce,
|
persist_window_geometry_after_debounce,
|
||||||
|
// State sync, not UI — runs even under `headless()`
|
||||||
|
// so the live `bevy::ui::UiScale` always mirrors the
|
||||||
|
// setting; rides the mutator spine so it is ordered
|
||||||
|
// after every settings writer this frame.
|
||||||
|
sync_ui_scale_resource,
|
||||||
)
|
)
|
||||||
.chain()
|
.chain()
|
||||||
.in_set(SettingsMutation),
|
.in_set(SettingsMutation),
|
||||||
@@ -492,6 +506,7 @@ impl Plugin for SettingsPlugin {
|
|||||||
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
update_high_contrast_backgrounds.run_if(resource_changed::<SettingsResource>),
|
||||||
update_reduce_motion_text,
|
update_reduce_motion_text,
|
||||||
update_touch_input_mode_text,
|
update_touch_input_mode_text,
|
||||||
|
update_ui_scale_text,
|
||||||
update_tooltip_delay_text,
|
update_tooltip_delay_text,
|
||||||
update_time_bonus_multiplier_text,
|
update_time_bonus_multiplier_text,
|
||||||
update_replay_move_interval_text,
|
update_replay_move_interval_text,
|
||||||
|
|||||||
@@ -736,3 +736,60 @@ fn scroll_clamps_offset_to_zero_at_top() {
|
|||||||
"scrolling past top must clamp to 0, got {offset}"
|
"scrolling past top must clamp to 0, got {offset}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Phase K: UI scale
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_scale_steps_cycle_and_wrap() {
|
||||||
|
assert_eq!(next_ui_scale(0.9), 1.0);
|
||||||
|
assert_eq!(next_ui_scale(1.0), 1.15);
|
||||||
|
assert_eq!(next_ui_scale(1.15), 1.3);
|
||||||
|
assert_eq!(next_ui_scale(1.3), 0.9, "the cycle must wrap");
|
||||||
|
// A hand-edited in-between value advances to the next larger step.
|
||||||
|
assert_eq!(next_ui_scale(1.05), 1.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_scale_label_formats_as_percent() {
|
||||||
|
assert_eq!(ui_scale_label(0.9), "90%");
|
||||||
|
assert_eq!(ui_scale_label(1.0), "100%");
|
||||||
|
assert_eq!(ui_scale_label(1.15), "115%");
|
||||||
|
assert_eq!(ui_scale_label(1.3), "130%");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_scale_setting_syncs_the_bevy_resource() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
app.insert_resource(UiScale(1.0));
|
||||||
|
app.world_mut()
|
||||||
|
.resource_mut::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.ui_scale = 1.3;
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
(app.world().resource::<UiScale>().0 - 1.3).abs() < f32::EPSILON,
|
||||||
|
"Settings::ui_scale must drive bevy::ui::UiScale"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ui_scale_out_of_range_sanitizes_on_load() {
|
||||||
|
use solitaire_data::settings::{UI_SCALE_MAX, UI_SCALE_MIN};
|
||||||
|
|
||||||
|
let wild = Settings {
|
||||||
|
ui_scale: 5.0,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(wild.ui_scale, UI_SCALE_MAX);
|
||||||
|
|
||||||
|
let tiny = Settings {
|
||||||
|
ui_scale: 0.1,
|
||||||
|
..Settings::default()
|
||||||
|
}
|
||||||
|
.sanitized();
|
||||||
|
assert_eq!(tiny.ui_scale, UI_SCALE_MIN);
|
||||||
|
}
|
||||||
|
|||||||
@@ -326,6 +326,15 @@ fn spawn_accessibility_tab(
|
|||||||
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
"One-tap: tap a card to auto-move it. Tap to select: first tap selects a card, second tap on a pile moves it.",
|
||||||
font_res,
|
font_res,
|
||||||
);
|
);
|
||||||
|
toggle_row(
|
||||||
|
body,
|
||||||
|
"UI Scale",
|
||||||
|
UiScaleText,
|
||||||
|
ui_scale_label(settings.ui_scale),
|
||||||
|
SettingsButton::CycleUiScale,
|
||||||
|
"Scales all menus, buttons, and HUD text. The table itself always fits the screen.",
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
|
tooltip_delay_row(body, settings.tooltip_delay_secs, font_res);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -515,6 +515,58 @@ pub(super) fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInput
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The four UI-scale steps the settings row cycles through (Phase K).
|
||||||
|
pub(super) const UI_SCALE_STEPS: [f32; 4] = [0.9, 1.0, 1.15, 1.3];
|
||||||
|
|
||||||
|
/// The next UI-scale step after `current`, wrapping 130 % → 90 %. A
|
||||||
|
/// hand-edited value between steps advances to the first step larger
|
||||||
|
/// than it, so the cycle always makes visible progress.
|
||||||
|
pub(super) fn next_ui_scale(current: f32) -> f32 {
|
||||||
|
for step in UI_SCALE_STEPS {
|
||||||
|
if step > current + 0.001 {
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
UI_SCALE_STEPS[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Display string for the UI-scale row, e.g. `"115%"`.
|
||||||
|
pub(super) fn ui_scale_label(scale: f32) -> String {
|
||||||
|
format!("{:.0}%", scale * 100.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the live UI-scale value text whenever settings change.
|
||||||
|
pub(super) fn update_ui_scale_text(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
mut text_nodes: Query<&mut Text, With<UiScaleText>>,
|
||||||
|
) {
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for mut text in &mut text_nodes {
|
||||||
|
**text = ui_scale_label(settings.0.ui_scale);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Applies `Settings::ui_scale` to the live [`bevy::ui::UiScale`]
|
||||||
|
/// resource — at startup (first change tick) and whenever the setting
|
||||||
|
/// changes. Absent under `MinimalPlugins` (no `bevy_ui`), hence the
|
||||||
|
/// `Option`; the table is unaffected either way (`compute_layout`
|
||||||
|
/// owns world-space sizing, not UI scale).
|
||||||
|
pub(super) fn sync_ui_scale_resource(
|
||||||
|
settings: Res<SettingsResource>,
|
||||||
|
ui_scale: Option<ResMut<UiScale>>,
|
||||||
|
) {
|
||||||
|
let Some(mut ui_scale) = ui_scale else { return };
|
||||||
|
if !settings.is_changed() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let target = settings.0.ui_scale;
|
||||||
|
if (ui_scale.0 - target).abs() > f32::EPSILON {
|
||||||
|
ui_scale.0 = target;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Display string for the "Smart window size" toggle. The argument
|
/// Display string for the "Smart window size" toggle. The argument
|
||||||
/// is the *enabled* state (i.e. the inverse of the underlying
|
/// is the *enabled* state (i.e. the inverse of the underlying
|
||||||
/// `disable_smart_default_size` field) so reading the label gives
|
/// `disable_smart_default_size` field) so reading the label gives
|
||||||
|
|||||||
@@ -14,6 +14,9 @@
|
|||||||
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
//! filesystem-based; the plugin is gated out on wasm32 alongside
|
||||||
//! `SyncPlugin`.
|
//! `SyncPlugin`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use bevy::asset::RenderAssetUsages;
|
||||||
use bevy::prelude::*;
|
use bevy::prelude::*;
|
||||||
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
use bevy::tasks::{AsyncComputeTaskPool, Task, futures_lite::future};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -105,6 +108,21 @@ struct InstallTask(Option<(String, Task<InstallResult>)>);
|
|||||||
#[derive(Resource, Default)]
|
#[derive(Resource, Default)]
|
||||||
struct StoreBaseUrl(Option<String>);
|
struct StoreBaseUrl(Option<String>);
|
||||||
|
|
||||||
|
/// Decoded preview thumbnails by theme id (Phase H). Session-lifetime:
|
||||||
|
/// previews survive closing and reopening the store, so a revisit
|
||||||
|
/// renders instantly without refetching.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PreviewCache(HashMap<String, Handle<Image>>);
|
||||||
|
|
||||||
|
/// One in-flight preview download: raw PNG bytes or the fetch error.
|
||||||
|
type PreviewFetch = Task<Result<Vec<u8>, ThemeStoreError>>;
|
||||||
|
|
||||||
|
/// In-flight preview downloads, one per catalog entry that advertises
|
||||||
|
/// a `preview_url` not already in [`PreviewCache`]. `String` is the
|
||||||
|
/// theme id the bytes belong to.
|
||||||
|
#[derive(Resource, Default)]
|
||||||
|
struct PreviewTasks(Vec<(String, PreviewFetch)>);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Plugin
|
// Plugin
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -118,6 +136,8 @@ impl Plugin for ThemeStorePlugin {
|
|||||||
.init_resource::<CatalogTask>()
|
.init_resource::<CatalogTask>()
|
||||||
.init_resource::<InstallTask>()
|
.init_resource::<InstallTask>()
|
||||||
.init_resource::<StoreBaseUrl>()
|
.init_resource::<StoreBaseUrl>()
|
||||||
|
.init_resource::<PreviewCache>()
|
||||||
|
.init_resource::<PreviewTasks>()
|
||||||
// Esc-close reads keyboard input; register defensively so
|
// Esc-close reads keyboard input; register defensively so
|
||||||
// the plugin works under MinimalPlugins in tests.
|
// the plugin works under MinimalPlugins in tests.
|
||||||
.init_resource::<ButtonInput<KeyCode>>()
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
@@ -129,6 +149,7 @@ impl Plugin for ThemeStorePlugin {
|
|||||||
(
|
(
|
||||||
handle_open_request,
|
handle_open_request,
|
||||||
poll_catalog_task,
|
poll_catalog_task,
|
||||||
|
poll_preview_tasks,
|
||||||
handle_install_buttons,
|
handle_install_buttons,
|
||||||
poll_install_task,
|
poll_install_task,
|
||||||
handle_close_button,
|
handle_close_button,
|
||||||
@@ -176,6 +197,7 @@ fn handle_open_request(
|
|||||||
mut catalog_task: ResMut<CatalogTask>,
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
mut store_base: ResMut<StoreBaseUrl>,
|
mut store_base: ResMut<StoreBaseUrl>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -207,16 +229,28 @@ fn handle_open_request(
|
|||||||
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
rt.block_on(async { ThemeStoreClient::new(base_url).fetch_catalog().await })
|
||||||
}));
|
}));
|
||||||
|
|
||||||
spawn_store_modal(&mut commands, &catalog_state, None, font_res.as_deref());
|
spawn_store_modal(
|
||||||
|
&mut commands,
|
||||||
|
&catalog_state,
|
||||||
|
None,
|
||||||
|
&previews,
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Polls the catalog fetch; on completion updates [`CatalogState`] and
|
/// Polls the catalog fetch; on completion updates [`CatalogState`],
|
||||||
|
/// kicks off preview downloads for entries not yet in the cache, and
|
||||||
/// rebuilds the modal if it is still open.
|
/// rebuilds the modal if it is still open.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn poll_catalog_task(
|
fn poll_catalog_task(
|
||||||
mut catalog_task: ResMut<CatalogTask>,
|
mut catalog_task: ResMut<CatalogTask>,
|
||||||
mut catalog_state: ResMut<CatalogState>,
|
mut catalog_state: ResMut<CatalogState>,
|
||||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
registry: Option<Res<ThemeRegistry>>,
|
registry: Option<Res<ThemeRegistry>>,
|
||||||
|
rt: Option<Res<TokioRuntimeResource>>,
|
||||||
|
store_base: Res<StoreBaseUrl>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
|
mut preview_tasks: ResMut<PreviewTasks>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -229,7 +263,29 @@ fn poll_catalog_task(
|
|||||||
catalog_task.0 = None;
|
catalog_task.0 = None;
|
||||||
|
|
||||||
*catalog_state = match result {
|
*catalog_state = match result {
|
||||||
Ok(entries) => CatalogState::Loaded(entries),
|
Ok(entries) => {
|
||||||
|
// Fetch previews for anything new. Decorative — failures
|
||||||
|
// just leave the row text-only, so errors only log.
|
||||||
|
if let (Some(rt), Some(base_url)) = (rt.as_ref(), store_base.0.as_deref()) {
|
||||||
|
for entry in entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.preview_url.is_some() && !previews.0.contains_key(&e.id))
|
||||||
|
{
|
||||||
|
let rt = rt.0.clone();
|
||||||
|
let base_url = base_url.to_owned();
|
||||||
|
let entry = entry.clone();
|
||||||
|
preview_tasks.0.push((
|
||||||
|
entry.id.clone(),
|
||||||
|
AsyncComputeTaskPool::get().spawn(async move {
|
||||||
|
rt.block_on(async {
|
||||||
|
ThemeStoreClient::new(base_url).fetch_preview(&entry).await
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CatalogState::Loaded(entries)
|
||||||
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!("theme store: catalog fetch failed: {e}");
|
warn!("theme store: catalog fetch failed: {e}");
|
||||||
CatalogState::Error(e.to_string())
|
CatalogState::Error(e.to_string())
|
||||||
@@ -241,10 +297,69 @@ fn poll_catalog_task(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
&catalog_state,
|
&catalog_state,
|
||||||
registry.as_deref(),
|
registry.as_deref(),
|
||||||
|
&previews,
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Polls in-flight preview downloads. Each finished PNG is decoded into
|
||||||
|
/// an [`Image`] asset and cached by theme id; the open modal rebuilds
|
||||||
|
/// once per frame that added at least one preview, so thumbnails pop in
|
||||||
|
/// as they arrive. Failures log and leave the row text-only. Skipped
|
||||||
|
/// entirely when `Assets<Image>` is absent (`MinimalPlugins` tests).
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn poll_preview_tasks(
|
||||||
|
mut preview_tasks: ResMut<PreviewTasks>,
|
||||||
|
mut previews: ResMut<PreviewCache>,
|
||||||
|
images: Option<ResMut<Assets<Image>>>,
|
||||||
|
catalog_state: Res<CatalogState>,
|
||||||
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
|
registry: Option<Res<ThemeRegistry>>,
|
||||||
|
mut commands: Commands,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
) {
|
||||||
|
if preview_tasks.0.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(mut images) = images else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut added = false;
|
||||||
|
preview_tasks.0.retain_mut(|(id, task)| {
|
||||||
|
let Some(result) = future::block_on(future::poll_once(task)) else {
|
||||||
|
return true; // still downloading
|
||||||
|
};
|
||||||
|
match result {
|
||||||
|
Ok(bytes) => match image::load_from_memory(&bytes) {
|
||||||
|
Ok(dyn_img) => {
|
||||||
|
let handle = images.add(Image::from_dynamic(
|
||||||
|
dyn_img,
|
||||||
|
true,
|
||||||
|
RenderAssetUsages::RENDER_WORLD,
|
||||||
|
));
|
||||||
|
previews.0.insert(id.clone(), handle);
|
||||||
|
added = true;
|
||||||
|
}
|
||||||
|
Err(e) => warn!("theme store: preview for '{id}' failed to decode: {e}"),
|
||||||
|
},
|
||||||
|
Err(e) => warn!("theme store: preview fetch for '{id}' failed: {e}"),
|
||||||
|
}
|
||||||
|
false
|
||||||
|
});
|
||||||
|
|
||||||
|
if added {
|
||||||
|
rebuild_open_modal(
|
||||||
|
&screens,
|
||||||
|
&mut commands,
|
||||||
|
&catalog_state,
|
||||||
|
registry.as_deref(),
|
||||||
|
&previews,
|
||||||
|
font_res.as_deref(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Starts a download + import task when an Install button is pressed.
|
/// Starts a download + import task when an Install button is pressed.
|
||||||
/// One install at a time; repeat clicks while busy are ignored.
|
/// One install at a time; repeat clicks while busy are ignored.
|
||||||
fn handle_install_buttons(
|
fn handle_install_buttons(
|
||||||
@@ -308,6 +423,7 @@ fn poll_install_task(
|
|||||||
screens: Query<Entity, With<ThemeStoreScreen>>,
|
screens: Query<Entity, With<ThemeStoreScreen>>,
|
||||||
mut info_toast: MessageWriter<InfoToastEvent>,
|
mut info_toast: MessageWriter<InfoToastEvent>,
|
||||||
mut warning_toast: MessageWriter<WarningToastEvent>,
|
mut warning_toast: MessageWriter<WarningToastEvent>,
|
||||||
|
previews: Res<PreviewCache>,
|
||||||
mut commands: Commands,
|
mut commands: Commands,
|
||||||
font_res: Option<Res<FontResource>>,
|
font_res: Option<Res<FontResource>>,
|
||||||
) {
|
) {
|
||||||
@@ -343,6 +459,7 @@ fn poll_install_task(
|
|||||||
&mut commands,
|
&mut commands,
|
||||||
&catalog_state,
|
&catalog_state,
|
||||||
registry.as_deref(),
|
registry.as_deref(),
|
||||||
|
&previews,
|
||||||
font_res.as_deref(),
|
font_res.as_deref(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -378,11 +495,12 @@ fn rebuild_open_modal(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
catalog_state: &CatalogState,
|
catalog_state: &CatalogState,
|
||||||
registry: Option<&ThemeRegistry>,
|
registry: Option<&ThemeRegistry>,
|
||||||
|
previews: &PreviewCache,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
for entity in screens {
|
for entity in screens {
|
||||||
commands.entity(entity).despawn();
|
commands.entity(entity).despawn();
|
||||||
spawn_store_modal(commands, catalog_state, registry, font_res);
|
spawn_store_modal(commands, catalog_state, registry, previews, font_res);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +509,7 @@ fn spawn_store_modal(
|
|||||||
commands: &mut Commands,
|
commands: &mut Commands,
|
||||||
catalog_state: &CatalogState,
|
catalog_state: &CatalogState,
|
||||||
registry: Option<&ThemeRegistry>,
|
registry: Option<&ThemeRegistry>,
|
||||||
|
previews: &PreviewCache,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
) {
|
) {
|
||||||
let body_font = TextFont {
|
let body_font = TextFont {
|
||||||
@@ -433,7 +552,15 @@ fn spawn_store_modal(
|
|||||||
for entry in entries {
|
for entry in entries {
|
||||||
let installed =
|
let installed =
|
||||||
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
registry.is_some_and(|registry| registry.find(&entry.id).is_some());
|
||||||
spawn_store_row(card, entry, installed, &body_font, &caption_font, font_res);
|
spawn_store_row(
|
||||||
|
card,
|
||||||
|
entry,
|
||||||
|
installed,
|
||||||
|
previews.0.get(&entry.id),
|
||||||
|
&body_font,
|
||||||
|
&caption_font,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,12 +579,20 @@ fn spawn_store_modal(
|
|||||||
commands.entity(scrim).insert(ScrimDismissible);
|
commands.entity(scrim).insert(ScrimDismissible);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One catalog row: name + author/size caption on the left, Install
|
/// Height of a row's preview thumbnail in logical pixels; the width
|
||||||
/// button (or "Installed" caption) on the right.
|
/// follows the theme's own `card_aspect` so a preview is never
|
||||||
|
/// stretched.
|
||||||
|
const PREVIEW_THUMB_HEIGHT_PX: f32 = 72.0;
|
||||||
|
|
||||||
|
/// One catalog row: preview thumbnail (when downloaded) + name +
|
||||||
|
/// author/size caption on the left, Install button (or "Installed"
|
||||||
|
/// caption) on the right.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn spawn_store_row(
|
fn spawn_store_row(
|
||||||
parent: &mut ChildSpawnerCommands,
|
parent: &mut ChildSpawnerCommands,
|
||||||
entry: &ThemeCatalogEntry,
|
entry: &ThemeCatalogEntry,
|
||||||
installed: bool,
|
installed: bool,
|
||||||
|
preview: Option<&Handle<Image>>,
|
||||||
body_font: &TextFont,
|
body_font: &TextFont,
|
||||||
caption_font: &TextFont,
|
caption_font: &TextFont,
|
||||||
font_res: Option<&FontResource>,
|
font_res: Option<&FontResource>,
|
||||||
@@ -477,25 +612,45 @@ fn spawn_store_row(
|
|||||||
))
|
))
|
||||||
.with_children(|row| {
|
.with_children(|row| {
|
||||||
row.spawn(Node {
|
row.spawn(Node {
|
||||||
flex_direction: FlexDirection::Column,
|
flex_direction: FlexDirection::Row,
|
||||||
row_gap: VAL_SPACE_2,
|
align_items: AlignItems::Center,
|
||||||
|
column_gap: VAL_SPACE_3,
|
||||||
..default()
|
..default()
|
||||||
})
|
})
|
||||||
.with_children(|col| {
|
.with_children(|left| {
|
||||||
col.spawn((
|
if let Some(handle) = preview {
|
||||||
Text::new(entry.name.clone()),
|
let (aw, ah) = entry.card_aspect;
|
||||||
body_font.clone(),
|
let width = PREVIEW_THUMB_HEIGHT_PX * aw.max(1) as f32 / ah.max(1) as f32;
|
||||||
TextColor(TEXT_PRIMARY),
|
left.spawn((
|
||||||
));
|
ImageNode::new(handle.clone()),
|
||||||
col.spawn((
|
Node {
|
||||||
Text::new(format!(
|
width: Val::Px(width),
|
||||||
"by {} — {} KB",
|
height: Val::Px(PREVIEW_THUMB_HEIGHT_PX),
|
||||||
entry.author,
|
..default()
|
||||||
entry.size_bytes.div_ceil(1024)
|
},
|
||||||
)),
|
));
|
||||||
caption_font.clone(),
|
}
|
||||||
TextColor(TEXT_SECONDARY),
|
left.spawn(Node {
|
||||||
));
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|col| {
|
||||||
|
col.spawn((
|
||||||
|
Text::new(entry.name.clone()),
|
||||||
|
body_font.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
col.spawn((
|
||||||
|
Text::new(format!(
|
||||||
|
"by {} — {} KB",
|
||||||
|
entry.author,
|
||||||
|
entry.size_bytes.div_ceil(1024)
|
||||||
|
)),
|
||||||
|
caption_font.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
if installed {
|
if installed {
|
||||||
row.spawn((
|
row.spawn((
|
||||||
@@ -661,4 +816,85 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert_eq!(screen_count(&mut app), 1);
|
assert_eq!(screen_count(&mut app), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Phase H part 2: preview thumbnails
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
fn catalog_entry(id: &str, preview: bool) -> ThemeCatalogEntry {
|
||||||
|
ThemeCatalogEntry {
|
||||||
|
id: id.into(),
|
||||||
|
name: id.into(),
|
||||||
|
author: "Test".into(),
|
||||||
|
version: "1.0.0".into(),
|
||||||
|
card_aspect: (2, 3),
|
||||||
|
size_bytes: 1024,
|
||||||
|
sha256: "00".into(),
|
||||||
|
download_url: format!("/api/themes/{id}/download"),
|
||||||
|
preview_url: preview.then(|| format!("/api/themes/{id}/preview")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A row whose theme id is in the [`PreviewCache`] renders an
|
||||||
|
/// `ImageNode` thumbnail; a row without one stays text-only.
|
||||||
|
#[test]
|
||||||
|
fn loaded_row_shows_preview_thumbnail_only_when_cached() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let cached = catalog_entry("with-preview", true);
|
||||||
|
let uncached = catalog_entry("without-preview", false);
|
||||||
|
|
||||||
|
let mut cache = PreviewCache::default();
|
||||||
|
cache
|
||||||
|
.0
|
||||||
|
.insert(cached.id.clone(), Handle::<Image>::default());
|
||||||
|
let state = CatalogState::Loaded(vec![cached, uncached]);
|
||||||
|
|
||||||
|
{
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
spawn_store_modal(&mut commands, &state, None, &cache, None);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let thumbs = app
|
||||||
|
.world_mut()
|
||||||
|
.query::<&ImageNode>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count();
|
||||||
|
assert_eq!(
|
||||||
|
thumbs, 1,
|
||||||
|
"exactly the cached entry must render a preview thumbnail"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The thumbnail width follows the theme's own card aspect so a
|
||||||
|
/// preview never renders stretched.
|
||||||
|
#[test]
|
||||||
|
fn preview_thumbnail_width_follows_card_aspect() {
|
||||||
|
let mut app = headless_app();
|
||||||
|
let mut entry = catalog_entry("wide", true);
|
||||||
|
entry.card_aspect = (1, 1); // square theme art
|
||||||
|
let mut cache = PreviewCache::default();
|
||||||
|
cache.0.insert(entry.id.clone(), Handle::<Image>::default());
|
||||||
|
let state = CatalogState::Loaded(vec![entry]);
|
||||||
|
|
||||||
|
{
|
||||||
|
let world = app.world_mut();
|
||||||
|
let mut commands = world.commands();
|
||||||
|
spawn_store_modal(&mut commands, &state, None, &cache, None);
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
let node = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<&Node, With<ImageNode>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("thumbnail node must exist");
|
||||||
|
assert_eq!(node.height, Val::Px(PREVIEW_THUMB_HEIGHT_PX));
|
||||||
|
assert_eq!(
|
||||||
|
node.width,
|
||||||
|
Val::Px(PREVIEW_THUMB_HEIGHT_PX),
|
||||||
|
"a 1:1 aspect must yield a square thumbnail"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,6 +178,32 @@ pub fn spawn_modal<M: Component, F>(
|
|||||||
z_panel: i32,
|
z_panel: i32,
|
||||||
build_card: F,
|
build_card: F,
|
||||||
) -> Entity
|
) -> Entity
|
||||||
|
where
|
||||||
|
F: FnOnce(&mut ChildSpawnerCommands),
|
||||||
|
{
|
||||||
|
spawn_modal_sized(
|
||||||
|
commands,
|
||||||
|
plugin_marker,
|
||||||
|
z_panel,
|
||||||
|
MODAL_CARD_MAX_WIDTH,
|
||||||
|
build_card,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default maximum card width in logical pixels — every [`spawn_modal`]
|
||||||
|
/// caller gets this. Surfaces that lay out side-by-side panes (e.g. the
|
||||||
|
/// two-pane Home on wide viewports) can raise it via [`spawn_modal_sized`].
|
||||||
|
pub const MODAL_CARD_MAX_WIDTH: f32 = 720.0;
|
||||||
|
|
||||||
|
/// [`spawn_modal`] with an explicit card `max_width`. Behaviour is
|
||||||
|
/// otherwise identical — same scrim, enter animation, and card chrome.
|
||||||
|
pub fn spawn_modal_sized<M: Component, F>(
|
||||||
|
commands: &mut Commands,
|
||||||
|
plugin_marker: M,
|
||||||
|
z_panel: i32,
|
||||||
|
max_width: f32,
|
||||||
|
build_card: F,
|
||||||
|
) -> Entity
|
||||||
where
|
where
|
||||||
F: FnOnce(&mut ChildSpawnerCommands),
|
F: FnOnce(&mut ChildSpawnerCommands),
|
||||||
{
|
{
|
||||||
@@ -235,7 +261,7 @@ where
|
|||||||
padding: UiRect::all(VAL_SPACE_5),
|
padding: UiRect::all(VAL_SPACE_5),
|
||||||
border: UiRect::all(Val::Px(1.0)),
|
border: UiRect::all(Val::Px(1.0)),
|
||||||
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
border_radius: BorderRadius::all(Val::Px(RADIUS_LG)),
|
||||||
max_width: Val::Px(720.0),
|
max_width: Val::Px(max_width),
|
||||||
align_items: AlignItems::Stretch,
|
align_items: AlignItems::Stretch,
|
||||||
..default()
|
..default()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,527 @@
|
|||||||
|
//! One-shot "What's new" card on the first launch after an update
|
||||||
|
//! (Phase I of the 2026-07 UI redesign).
|
||||||
|
//!
|
||||||
|
//! ObtainX updates install silently, so shipped features go unnoticed —
|
||||||
|
//! nobody found the theme store on their own. On the first launch where
|
||||||
|
//! the running release differs from `Settings::last_seen_whats_new`,
|
||||||
|
//! this plugin shows a single dismissible card summarising the latest
|
||||||
|
//! changelog section, then records the version so the card never
|
||||||
|
//! repeats. Fresh installs never see it: onboarding completion stamps
|
||||||
|
//! the current version silently, so the card only ever describes an
|
||||||
|
//! *upgrade*.
|
||||||
|
//!
|
||||||
|
//! # Launch beat
|
||||||
|
//!
|
||||||
|
//! Splash → (first run only: onboarding) → **what's new** → Home →
|
||||||
|
//! table. `spawn_home_on_launch` waits on [`WhatsNewPending`] the same
|
||||||
|
//! way it waits for the onboarding modal.
|
||||||
|
//!
|
||||||
|
//! # Version source
|
||||||
|
//!
|
||||||
|
//! The embedded `CHANGELOG.md`'s topmost release section provides both
|
||||||
|
//! the card's content and the "current version" for change detection —
|
||||||
|
//! one source of truth, no build-time version plumbing. (The APK
|
||||||
|
//! `versionName` comes from the release tag at package time and never
|
||||||
|
//! reaches Rust; the workspace `Cargo.toml` version is static.)
|
||||||
|
|
||||||
|
use bevy::input::ButtonInput;
|
||||||
|
use bevy::prelude::*;
|
||||||
|
use solitaire_data::save_settings_to;
|
||||||
|
|
||||||
|
use crate::font_plugin::FontResource;
|
||||||
|
use crate::onboarding_plugin::OnboardingScreen;
|
||||||
|
use crate::settings_plugin::{SettingsResource, SettingsStoragePath};
|
||||||
|
use crate::ui_modal::{
|
||||||
|
ButtonVariant, ModalScrim, spawn_modal, spawn_modal_actions, spawn_modal_button,
|
||||||
|
spawn_modal_header,
|
||||||
|
};
|
||||||
|
use crate::ui_theme::{
|
||||||
|
TEXT_PRIMARY, TEXT_SECONDARY, TYPE_BODY, TYPE_CAPTION, VAL_SPACE_1, VAL_SPACE_2, Z_MODAL_PANEL,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The changelog ships inside the binary — small, changes only at
|
||||||
|
/// release cadence, and the card must work offline (§4.2).
|
||||||
|
const CHANGELOG: &str = include_str!("../../CHANGELOG.md");
|
||||||
|
|
||||||
|
/// Most bullets shown on the card; a giant release stays skimmable.
|
||||||
|
const MAX_CARD_BULLETS: usize = 8;
|
||||||
|
|
||||||
|
/// Marker on the What's-new modal's scrim root.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
pub struct WhatsNewScreen;
|
||||||
|
|
||||||
|
/// Marker on the card's "Got it" button.
|
||||||
|
#[derive(Component, Debug)]
|
||||||
|
struct WhatsNewCloseButton;
|
||||||
|
|
||||||
|
/// `true` until this plugin has made its launch-beat decision — either
|
||||||
|
/// the card was shown and dismissed, or there was nothing to show.
|
||||||
|
/// `spawn_home_on_launch` waits on this so the card gets the beat
|
||||||
|
/// before Home.
|
||||||
|
#[derive(Resource, Debug)]
|
||||||
|
pub struct WhatsNewPending(pub bool);
|
||||||
|
|
||||||
|
impl Default for WhatsNewPending {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One display line of the card.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum NoteLine {
|
||||||
|
/// A `###` section heading ("Added", "Fixed", …).
|
||||||
|
Heading(String),
|
||||||
|
/// The lead sentence of one changelog bullet.
|
||||||
|
Bullet(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The changelog's topmost release section, reduced to card content.
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct ReleaseNotes {
|
||||||
|
/// Version string without the `v` prefix, e.g. `"0.46.0"`.
|
||||||
|
version: String,
|
||||||
|
lines: Vec<NoteLine>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The version of the topmost changelog release — the engine's notion
|
||||||
|
/// of "the running release". Empty only if the changelog is malformed.
|
||||||
|
pub fn current_release_version() -> String {
|
||||||
|
parse_latest_release(CHANGELOG).map_or_else(String::new, |notes| notes.version)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the first `## [x.y.z]` section of `changelog` into card
|
||||||
|
/// content. `## [Unreleased]` is skipped; `### Internal` subsections
|
||||||
|
/// are dropped (players don't care about CI); each bullet is reduced
|
||||||
|
/// to its lead sentence with markdown emphasis stripped.
|
||||||
|
fn parse_latest_release(changelog: &str) -> Option<ReleaseNotes> {
|
||||||
|
let mut lines_iter = changelog.lines();
|
||||||
|
let mut version = None;
|
||||||
|
for line in lines_iter.by_ref() {
|
||||||
|
if let Some(rest) = line.strip_prefix("## [") {
|
||||||
|
let (v, _) = rest.split_once(']')?;
|
||||||
|
if v.eq_ignore_ascii_case("Unreleased") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
version = Some(v.to_string());
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let version = version?;
|
||||||
|
|
||||||
|
let mut lines = Vec::new();
|
||||||
|
let mut skipping_section = false;
|
||||||
|
let mut current_bullet: Option<String> = None;
|
||||||
|
|
||||||
|
let flush = |bullet: &mut Option<String>, lines: &mut Vec<NoteLine>| {
|
||||||
|
if let Some(text) = bullet.take() {
|
||||||
|
lines.push(NoteLine::Bullet(lead_sentence(&text)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for line in lines_iter {
|
||||||
|
if line.starts_with("## [") {
|
||||||
|
break; // next (older) release section
|
||||||
|
}
|
||||||
|
if let Some(heading) = line.strip_prefix("### ") {
|
||||||
|
flush(&mut current_bullet, &mut lines);
|
||||||
|
skipping_section = heading.trim().eq_ignore_ascii_case("internal");
|
||||||
|
if !skipping_section {
|
||||||
|
lines.push(NoteLine::Heading(heading.trim().to_string()));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if skipping_section {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(rest) = line.strip_prefix("- ") {
|
||||||
|
flush(&mut current_bullet, &mut lines);
|
||||||
|
current_bullet = Some(rest.trim().to_string());
|
||||||
|
} else if current_bullet.is_some() && !line.trim().is_empty() {
|
||||||
|
// Wrapped continuation of the current bullet.
|
||||||
|
if let Some(bullet) = current_bullet.as_mut() {
|
||||||
|
bullet.push(' ');
|
||||||
|
bullet.push_str(line.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush(&mut current_bullet, &mut lines);
|
||||||
|
|
||||||
|
Some(ReleaseNotes { version, lines })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips markdown emphasis / code ticks and truncates to the first
|
||||||
|
/// sentence — changelog bullets lead with a bold summary sentence, and
|
||||||
|
/// that is exactly the card-sized version.
|
||||||
|
fn lead_sentence(bullet: &str) -> String {
|
||||||
|
let stripped: String = bullet.replace("**", "").replace('`', "");
|
||||||
|
// Trailing "(#123)" references never survive the sentence cut, but
|
||||||
|
// guard against a bullet that is only a reference.
|
||||||
|
let end = stripped.find(". ").map_or(stripped.len(), |i| i + 1);
|
||||||
|
stripped[..end].trim().trim_end_matches('.').to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers the launch-gate spawn system and the dismiss handlers.
|
||||||
|
pub struct WhatsNewPlugin;
|
||||||
|
|
||||||
|
impl Plugin for WhatsNewPlugin {
|
||||||
|
fn build(&self, app: &mut App) {
|
||||||
|
app.init_resource::<WhatsNewPending>()
|
||||||
|
.init_resource::<ButtonInput<KeyCode>>()
|
||||||
|
.add_systems(
|
||||||
|
Update,
|
||||||
|
(maybe_spawn_whats_new, handle_whats_new_close).chain(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the card once the launch surface is clear, or stands down when
|
||||||
|
/// there is nothing to show. See the module docs for the exact beat.
|
||||||
|
///
|
||||||
|
/// The seen-version stamp persists on *spawn*, not dismissal — if the
|
||||||
|
/// app dies with the card open the player has still seen it once, and
|
||||||
|
/// the card must never nag.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn maybe_spawn_whats_new(
|
||||||
|
mut commands: Commands,
|
||||||
|
mut pending: ResMut<WhatsNewPending>,
|
||||||
|
splash: Query<(), With<crate::splash_plugin::SplashRoot>>,
|
||||||
|
onboarding: Query<(), With<OnboardingScreen>>,
|
||||||
|
restore_prompts: Query<(), With<crate::game_plugin::RestorePromptScreen>>,
|
||||||
|
pending_restore: Option<Res<crate::game_plugin::PendingRestoredGame>>,
|
||||||
|
other_scrims: Query<(), With<ModalScrim>>,
|
||||||
|
mut settings: Option<ResMut<SettingsResource>>,
|
||||||
|
storage_path: Option<Res<SettingsStoragePath>>,
|
||||||
|
font_res: Option<Res<FontResource>>,
|
||||||
|
screens: Query<(), With<WhatsNewScreen>>,
|
||||||
|
) {
|
||||||
|
if !pending.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !screens.is_empty() {
|
||||||
|
// Card already open — the seen-version stamp landed on spawn,
|
||||||
|
// so without this guard the next frame would read "already
|
||||||
|
// seen" and release the beat under the open card.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !splash.is_empty()
|
||||||
|
|| !onboarding.is_empty()
|
||||||
|
|| !restore_prompts.is_empty()
|
||||||
|
|| pending_restore.as_ref().is_some_and(|p| p.0.is_some())
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(settings) = settings.as_mut() else {
|
||||||
|
// Headless / no settings wired: nothing to compare against.
|
||||||
|
pending.0 = false;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !settings.0.first_run_complete {
|
||||||
|
// Fresh install mid-onboarding — completion stamps the version
|
||||||
|
// itself (see `complete_onboarding`), which resolves this gate
|
||||||
|
// on a later frame without ever showing the card.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(notes) = parse_latest_release(CHANGELOG) else {
|
||||||
|
pending.0 = false;
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if settings.0.last_seen_whats_new == notes.version {
|
||||||
|
pending.0 = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if !other_scrims.is_empty() {
|
||||||
|
// Another modal owns the beat right now; try again next frame.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
spawn_whats_new_card(&mut commands, ¬es, font_res.as_deref());
|
||||||
|
|
||||||
|
settings.0.last_seen_whats_new = notes.version.clone();
|
||||||
|
if let Some(p) = storage_path
|
||||||
|
&& let Some(path) = p.0.as_deref()
|
||||||
|
&& let Err(e) = save_settings_to(path, &settings.0)
|
||||||
|
{
|
||||||
|
warn!("whats-new: failed to persist seen version: {e}");
|
||||||
|
}
|
||||||
|
// `pending` stays true until dismissal so Home keeps waiting.
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dismisses the card on "Got it" or Esc and releases the launch beat.
|
||||||
|
fn handle_whats_new_close(
|
||||||
|
mut commands: Commands,
|
||||||
|
keys: Option<Res<ButtonInput<KeyCode>>>,
|
||||||
|
buttons: Query<&Interaction, (With<WhatsNewCloseButton>, Changed<Interaction>)>,
|
||||||
|
screens: Query<Entity, With<WhatsNewScreen>>,
|
||||||
|
other_scrims: Query<(), (With<ModalScrim>, Without<WhatsNewScreen>)>,
|
||||||
|
mut pending: ResMut<WhatsNewPending>,
|
||||||
|
) {
|
||||||
|
if screens.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let click = buttons.iter().any(|i| *i == Interaction::Pressed);
|
||||||
|
let esc = keys.is_some_and(|k| k.just_pressed(KeyCode::Escape)) && other_scrims.is_empty();
|
||||||
|
if !click && !esc {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for entity in &screens {
|
||||||
|
commands.entity(entity).despawn();
|
||||||
|
}
|
||||||
|
pending.0 = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Spawns the card: header with the version, the parsed changelog
|
||||||
|
/// lines, and a "Got it" action.
|
||||||
|
fn spawn_whats_new_card(
|
||||||
|
commands: &mut Commands,
|
||||||
|
notes: &ReleaseNotes,
|
||||||
|
font_res: Option<&FontResource>,
|
||||||
|
) {
|
||||||
|
let font_handle = font_res.map(|f| f.0.clone()).unwrap_or_default();
|
||||||
|
let font_heading = TextFont {
|
||||||
|
font: font_handle.clone(),
|
||||||
|
font_size: TYPE_CAPTION,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
let font_bullet = TextFont {
|
||||||
|
font: font_handle,
|
||||||
|
font_size: TYPE_BODY,
|
||||||
|
..default()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deliberately NOT ScrimDismissible: scrim-tap despawns without
|
||||||
|
// running the close handler, which would leave the launch beat
|
||||||
|
// held and Home never spawning. Esc and "Got it" both release it.
|
||||||
|
let title = format!("What's new in v{}", notes.version);
|
||||||
|
spawn_modal(commands, WhatsNewScreen, Z_MODAL_PANEL, |card| {
|
||||||
|
spawn_modal_header(card, &title, font_res);
|
||||||
|
|
||||||
|
card.spawn(Node {
|
||||||
|
flex_direction: FlexDirection::Column,
|
||||||
|
row_gap: VAL_SPACE_2,
|
||||||
|
width: Val::Percent(100.0),
|
||||||
|
max_height: Val::Vh(60.0),
|
||||||
|
overflow: Overflow::scroll_y(),
|
||||||
|
..default()
|
||||||
|
})
|
||||||
|
.with_children(|body| {
|
||||||
|
let mut bullets_shown = 0usize;
|
||||||
|
for line in ¬es.lines {
|
||||||
|
match line {
|
||||||
|
NoteLine::Heading(heading) => {
|
||||||
|
body.spawn((
|
||||||
|
Text::new(heading.clone()),
|
||||||
|
font_heading.clone(),
|
||||||
|
TextColor(TEXT_SECONDARY),
|
||||||
|
Node {
|
||||||
|
margin: UiRect::top(VAL_SPACE_1),
|
||||||
|
..default()
|
||||||
|
},
|
||||||
|
));
|
||||||
|
}
|
||||||
|
NoteLine::Bullet(text) => {
|
||||||
|
if bullets_shown >= MAX_CARD_BULLETS {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
bullets_shown += 1;
|
||||||
|
body.spawn((
|
||||||
|
Text::new(format!("\u{2022} {text}")),
|
||||||
|
font_bullet.clone(),
|
||||||
|
TextColor(TEXT_PRIMARY),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
spawn_modal_actions(card, |actions| {
|
||||||
|
spawn_modal_button(
|
||||||
|
actions,
|
||||||
|
WhatsNewCloseButton,
|
||||||
|
"Got it",
|
||||||
|
None,
|
||||||
|
ButtonVariant::Primary,
|
||||||
|
font_res,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use solitaire_data::Settings;
|
||||||
|
|
||||||
|
const SAMPLE: &str = "# Changelog\n\n## [Unreleased]\n\n## [0.46.0] — 2026-07-13\n\n\
|
||||||
|
### Added\n\n- **Theme-store previews.** The store modal now shows each theme's\n \
|
||||||
|
preview image next to its name. (#179)\n- **Hint ghost preview.** You see the move. (#179)\n\n\
|
||||||
|
### Internal\n\n- **CI is faster.** Nobody cares in-game. (#176)\n\n\
|
||||||
|
### Fixed\n\n- **Touch onboarding copy.** No more left-click. (#178)\n\n\
|
||||||
|
## [0.45.0] — 2026-07-13\n\n### Added\n\n- Old stuff.\n";
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_top_section_version_and_skips_unreleased() {
|
||||||
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
||||||
|
assert_eq!(notes.version, "0.46.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn drops_internal_sections_and_older_releases() {
|
||||||
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
||||||
|
let headings: Vec<&str> = notes
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.filter_map(|l| match l {
|
||||||
|
NoteLine::Heading(h) => Some(h.as_str()),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(headings, vec!["Added", "Fixed"]);
|
||||||
|
assert!(
|
||||||
|
!notes
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("CI is faster"))),
|
||||||
|
"Internal bullets must not reach the card"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!notes
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.any(|l| matches!(l, NoteLine::Bullet(b) if b.contains("Old stuff"))),
|
||||||
|
"older release sections must not bleed in"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bullets_reduce_to_their_lead_sentence() {
|
||||||
|
let notes = parse_latest_release(SAMPLE).expect("sample must parse");
|
||||||
|
assert!(
|
||||||
|
notes
|
||||||
|
.lines
|
||||||
|
.contains(&NoteLine::Bullet("Theme-store previews".to_string()))
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
notes
|
||||||
|
.lines
|
||||||
|
.contains(&NoteLine::Bullet("Hint ghost preview".to_string()))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn embedded_changelog_parses_to_a_nonempty_version() {
|
||||||
|
let version = current_release_version();
|
||||||
|
assert!(
|
||||||
|
!version.is_empty(),
|
||||||
|
"the real CHANGELOG.md must yield a version"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
version.chars().next().is_some_and(|c| c.is_ascii_digit()),
|
||||||
|
"version must be bare (no v prefix): {version}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn app_with(settings: Settings) -> App {
|
||||||
|
let mut app = App::new();
|
||||||
|
app.add_plugins(MinimalPlugins).add_plugins(WhatsNewPlugin);
|
||||||
|
app.insert_resource(SettingsResource(settings));
|
||||||
|
app.update();
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
fn card_count(app: &mut App) -> usize {
|
||||||
|
app.world_mut()
|
||||||
|
.query::<&WhatsNewScreen>()
|
||||||
|
.iter(app.world())
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upgrade_shows_card_and_stamps_version() {
|
||||||
|
let mut app = app_with(Settings {
|
||||||
|
first_run_complete: true,
|
||||||
|
last_seen_whats_new: "0.1.0".into(),
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(card_count(&mut app), 1, "an upgrade must show the card");
|
||||||
|
assert_eq!(
|
||||||
|
app.world()
|
||||||
|
.resource::<SettingsResource>()
|
||||||
|
.0
|
||||||
|
.last_seen_whats_new,
|
||||||
|
current_release_version(),
|
||||||
|
"the seen version must stamp on spawn"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<WhatsNewPending>().0,
|
||||||
|
"the launch beat stays held until dismissal"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn seen_version_shows_nothing_and_releases_the_beat() {
|
||||||
|
let mut app = app_with(Settings {
|
||||||
|
first_run_complete: true,
|
||||||
|
last_seen_whats_new: current_release_version(),
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(card_count(&mut app), 0);
|
||||||
|
assert!(
|
||||||
|
!app.world().resource::<WhatsNewPending>().0,
|
||||||
|
"nothing to show must release the launch beat"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fresh_install_waits_for_onboarding_and_never_shows() {
|
||||||
|
let mut app = app_with(Settings {
|
||||||
|
first_run_complete: false,
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
assert_eq!(card_count(&mut app), 0);
|
||||||
|
assert!(
|
||||||
|
app.world().resource::<WhatsNewPending>().0,
|
||||||
|
"mid-onboarding the beat stays held"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Onboarding completion stamps the version (mirrored from
|
||||||
|
// complete_onboarding) — afterwards the card must stand down.
|
||||||
|
{
|
||||||
|
let mut settings = app.world_mut().resource_mut::<SettingsResource>();
|
||||||
|
settings.0.first_run_complete = true;
|
||||||
|
settings.0.last_seen_whats_new = current_release_version();
|
||||||
|
}
|
||||||
|
app.update();
|
||||||
|
assert_eq!(card_count(&mut app), 0, "fresh installs never see the card");
|
||||||
|
assert!(!app.world().resource::<WhatsNewPending>().0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn got_it_dismisses_and_releases_the_beat() {
|
||||||
|
let mut app = app_with(Settings {
|
||||||
|
first_run_complete: true,
|
||||||
|
..Settings::default()
|
||||||
|
});
|
||||||
|
app.update();
|
||||||
|
assert_eq!(card_count(&mut app), 1);
|
||||||
|
|
||||||
|
let button = app
|
||||||
|
.world_mut()
|
||||||
|
.query_filtered::<Entity, With<WhatsNewCloseButton>>()
|
||||||
|
.single(app.world())
|
||||||
|
.expect("Got it button must exist");
|
||||||
|
app.world_mut()
|
||||||
|
.entity_mut(button)
|
||||||
|
.insert(Interaction::Pressed);
|
||||||
|
app.update();
|
||||||
|
app.update();
|
||||||
|
|
||||||
|
assert_eq!(card_count(&mut app), 0, "Got it must dismiss the card");
|
||||||
|
assert!(!app.world().resource::<WhatsNewPending>().0);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user