Compare commits

..

5 Commits

Author SHA1 Message Date
funman300 ccf280ea50 fix(engine): add missing modal scrim guard to leaderboard panel
Android Release / build-apk (push) Successful in 4m29s
toggle_leaderboard_screen was missing the other_modal_scrims guard that
all other panel-toggle systems have. Pressing L (or the HUD button) while
any other modal was open would spawn a second ModalScrim on top of the
existing one, breaking z-ordering and leaving the first modal un-dismissable.

Adds:
  other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>
and the early-return guard before spawn_leaderboard_screen is called.

Closes #77

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:52:47 -07:00
funman300 f1d96012f1 fix(engine): add modal scrim guard to toggle_stats_screen (#75)
Pressing S (or the Stats HUD button) while another modal was open
(Settings, Profile, Leaderboard, etc.) would spawn a second ModalScrim
on top of the existing one, violating the one-scrim-at-a-time invariant.

Add other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>
matching the guard pattern used by every other modal-spawning system.
Also import ModalScrim which was previously not imported in this file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 15:19:24 -07:00
funman300 7eb1181e50 fix(server): accept nil user_id placeholder in push; use received_at for leaderboard (#73, #74)
Build and Deploy / build-and-push (push) Successful in 3m37s
- sync.rs: replace Uuid::nil() placeholder with the authenticated
  user's real UUID before the mismatch check so desktop client pushes
  no longer fail with 400 user_id mismatch (#73)
- replays.rs: use server-computed received_at instead of client-supplied
  header.recorded_at when updating leaderboard recorded_at to prevent
  timestamp spoofing (#74)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:41:02 -07:00
funman300 f444378184 fix(engine): toast on challenge exhaustion, block input during auto-complete (#71, #72)
- challenge_plugin: replace silent warn+return with InfoToast when all
  challenges are completed so the player gets clear feedback (#72)
- input_plugin: add AutoCompleteState guard to start_drag,
  touch_start_drag, and handle_double_tap so player input cannot race
  with the auto-complete move sequence (#71)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:24:09 -07:00
funman300 927598202e feat(engine,data): add tap-to-select touch input mode (#70)
- Add TouchInputMode enum (OneTap | TapToSelect) to solitaire_data settings
- Create TouchSelectionPlugin with TouchSelectionState resource and highlight
- Branch handle_double_tap: OneTap → existing auto-move, TapToSelect → two-tap flow
- Add Settings UI toggle row (Touch Input Mode) with TouchInputModeText marker
- Register TouchSelectionPlugin in CoreGamePlugin

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-28 14:04:40 -07:00
11 changed files with 405 additions and 14 deletions
+23
View File
@@ -62,6 +62,21 @@ pub enum SyncBackend {
}, },
} }
/// Touch input mode — controls what a single tap on a face-up card does.
///
/// Defaults to `OneTap` so existing behaviour is unchanged on upgrade.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum TouchInputMode {
/// A single tap immediately moves the card to its best destination
/// (foundation-first, then tableau). This is the original behaviour.
#[default]
OneTap,
/// A first tap *selects* the card/stack and highlights it; a second
/// tap on a valid destination pile performs the move. Tapping the
/// selection again, or an empty / invalid target, cancels without moving.
TapToSelect,
}
/// Persisted window size (in logical pixels) and screen position /// Persisted window size (in logical pixels) and screen position
/// (top-left corner, in physical pixels) — restored on next launch. /// (top-left corner, in physical pixels) — restored on next launch.
/// ///
@@ -264,6 +279,13 @@ pub struct Settings {
/// Defaults to `1` (the first site created in a fresh Matomo install). /// Defaults to `1` (the first site created in a fresh Matomo install).
#[serde(default = "default_matomo_site_id")] #[serde(default = "default_matomo_site_id")]
pub matomo_site_id: u32, pub matomo_site_id: u32,
/// Touch input mode — `OneTap` (default) auto-moves on first tap;
/// `TapToSelect` requires an explicit destination tap. Only affects
/// touch/Android; desktop mouse input is unchanged. Older
/// `settings.json` files deserialize cleanly to `OneTap` via
/// `#[serde(default)]`.
#[serde(default)]
pub touch_input_mode: TouchInputMode,
} }
fn default_draw_mode() -> DrawMode { fn default_draw_mode() -> DrawMode {
@@ -397,6 +419,7 @@ impl Default for Settings {
analytics_enabled: false, analytics_enabled: false,
matomo_url: None, matomo_url: None,
matomo_site_id: default_matomo_site_id(), matomo_site_id: default_matomo_site_id(),
touch_input_mode: TouchInputMode::OneTap,
} }
} }
} }
+3 -1
View File
@@ -93,7 +93,9 @@ fn handle_start_challenge_request(
return; return;
} }
let Some(seed) = challenge_seed_for(progress.0.challenge_index) else { let Some(seed) = challenge_seed_for(progress.0.challenge_index) else {
warn!("challenge seed list is empty"); info_toast.write(InfoToastEvent(
"You've completed all challenges! More coming soon.".into(),
));
return; return;
}; };
new_game.write(NewGameRequestEvent { new_game.write(NewGameRequestEvent {
+3 -1
View File
@@ -21,7 +21,8 @@ use crate::{
RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin, RadialMenuPlugin, ReplayOverlayPlugin, ReplayPlaybackPlugin, SafeAreaInsetsPlugin,
SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider, SelectionPlugin, SettingsPlugin, SplashPlugin, StatsPlugin, SyncPlugin, SyncProvider,
SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin, SyncSetupPlugin, TablePlugin, ThemePlugin, ThemeRegistryPlugin, TimeAttackPlugin,
UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin, WinSummaryPlugin, TouchSelectionPlugin, UiFocusPlugin, UiModalPlugin, UiTooltipPlugin, WeeklyGoalsPlugin,
WinSummaryPlugin,
}; };
/// Groups all Ferrous Solitaire gameplay plugins. /// Groups all Ferrous Solitaire gameplay plugins.
@@ -83,6 +84,7 @@ impl Plugin for CoreGamePlugin {
.add_plugins(InputPlugin) .add_plugins(InputPlugin)
.add_plugins(RadialMenuPlugin) .add_plugins(RadialMenuPlugin)
.add_plugins(SelectionPlugin) .add_plugins(SelectionPlugin)
.add_plugins(TouchSelectionPlugin)
.add_plugins(AnimationPlugin) .add_plugins(AnimationPlugin)
.add_plugins(FeedbackAnimPlugin) .add_plugins(FeedbackAnimPlugin)
.add_plugins(CardAnimationPlugin) .add_plugins(CardAnimationPlugin)
+59 -7
View File
@@ -31,6 +31,7 @@ use solitaire_core::game_state::GameState;
use solitaire_core::pile::PileType; use solitaire_core::pile::PileType;
use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau}; use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::auto_complete_plugin::AutoCompleteState;
use crate::card_animation::tuning::AnimationTuning; use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve}; use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC}; use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
@@ -48,7 +49,9 @@ use crate::radial_menu::RightClickRadialState;
use crate::replay_playback::ReplayPlaybackState; use crate::replay_playback::ReplayPlaybackState;
use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex}; use crate::resources::{DragState, GameInputConsumedResource, GameStateResource, HintCycleIndex};
use crate::selection_plugin::SelectionState; use crate::selection_plugin::SelectionState;
use crate::settings_plugin::SettingsResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::touch_selection_plugin::TouchSelectionState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING}; use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::game_state::DrawMode; use solitaire_core::game_state::DrawMode;
@@ -570,6 +573,7 @@ fn start_drag(
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,
touches: Option<Res<Touches>>, touches: Option<Res<Touches>>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
auto_complete: Option<Res<AutoCompleteState>>,
windows: Query<&Window, With<PrimaryWindow>>, windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
@@ -579,6 +583,9 @@ fn start_drag(
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
if auto_complete.is_some_and(|ac| ac.active) {
return;
}
// Only start a new drag when idle (no touch drag running either). // Only start a new drag when idle (no touch drag running either).
if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() { if !buttons.just_pressed(MouseButton::Left) || !drag.is_idle() {
return; return;
@@ -856,6 +863,7 @@ fn end_drag(
fn touch_start_drag( fn touch_start_drag(
mut touch_events: MessageReader<TouchInput>, mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
auto_complete: Option<Res<AutoCompleteState>>,
cameras: Query<(&Camera, &GlobalTransform)>, cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>, layout: Option<Res<LayoutResource>>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
@@ -864,6 +872,9 @@ fn touch_start_drag(
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
if auto_complete.is_some_and(|ac| ac.active) {
return;
}
// Only one drag at a time. // Only one drag at a time.
if !drag.is_idle() { if !drag.is_idle() {
return; return;
@@ -1501,13 +1512,18 @@ fn handle_double_tap(
mut touch_events: MessageReader<TouchInput>, mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>, paused: Option<Res<PausedResource>>,
radial: Option<Res<RightClickRadialState>>, radial: Option<Res<RightClickRadialState>>,
auto_complete: Option<Res<AutoCompleteState>>,
drag: Res<DragState>, drag: Res<DragState>,
game: Res<GameStateResource>, game: Res<GameStateResource>,
settings: Option<Res<SettingsResource>>,
mut touch_selection: Option<ResMut<TouchSelectionState>>,
mut moves: MessageWriter<MoveRequestEvent>, mut moves: MessageWriter<MoveRequestEvent>,
mut rejected: MessageWriter<MoveRejectedEvent>, mut rejected: MessageWriter<MoveRejectedEvent>,
mut commands: Commands, mut commands: Commands,
mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>, mut card_sprites: Query<(Entity, &CardEntity, &mut Sprite)>,
) { ) {
use solitaire_data::settings::TouchInputMode;
if paused.is_some_and(|p| p.0) { if paused.is_some_and(|p| p.0) {
return; return;
} }
@@ -1516,6 +1532,10 @@ fn handle_double_tap(
if radial.is_some_and(|r| r.is_active()) { if radial.is_some_and(|r| r.is_active()) {
return; return;
} }
// Auto-complete owns all moves during its sequence.
if auto_complete.is_some_and(|ac| ac.active) {
return;
}
let Some(active_id) = drag.active_touch_id else { let Some(active_id) = drag.active_touch_id else {
return; return;
@@ -1524,6 +1544,10 @@ fn handle_double_tap(
return; return;
} }
let tap_to_select = settings
.as_ref()
.is_some_and(|s| s.0.touch_input_mode == TouchInputMode::TapToSelect);
for event in touch_events.read() { for event in touch_events.read() {
if event.id != active_id || event.phase != TouchPhase::Ended { if event.id != active_id || event.phase != TouchPhase::Ended {
continue; continue;
@@ -1533,10 +1557,10 @@ fn handle_double_tap(
let Some(&top_card_id) = drag.cards.last() else { let Some(&top_card_id) = drag.cards.last() else {
return; return;
}; };
let Some(ref pile) = drag.origin_pile else { let Some(ref tapped_pile) = drag.origin_pile else {
return; return;
}; };
let Some(pile_cards) = game.0.piles.get(pile) else { let Some(pile_cards) = game.0.piles.get(tapped_pile) else {
return; return;
}; };
@@ -1547,6 +1571,34 @@ fn handle_double_tap(
return; return;
} }
// --- Tap-to-select mode ---
if tap_to_select {
if let Some(ref mut sel) = touch_selection {
if let Some((ref source_pile, ref source_cards)) = sel.selected.clone() {
// Second tap: this is the destination.
if tapped_pile == source_pile {
// Re-tap on selected source → cancel.
sel.clear();
return;
}
// Attempt the move. MoveRequestEvent carries validation;
// a rejection will fire MoveRejectedEvent automatically.
moves.write(MoveRequestEvent {
from: source_pile.clone(),
to: tapped_pile.clone(),
count: source_cards.len(),
});
sel.clear();
return;
}
// First tap: select the source.
sel.set(tapped_pile.clone(), drag.cards.clone());
}
return;
}
// --- One-tap auto-move (original behaviour) ---
// Priority 1: move single top card. // Priority 1: move single top card.
if let Some(dest) = best_destination(top_card, &game.0) { if let Some(dest) = best_destination(top_card, &game.0) {
for (entity, ce, mut sprite) in card_sprites.iter_mut() { for (entity, ce, mut sprite) in card_sprites.iter_mut() {
@@ -1559,7 +1611,7 @@ fn handle_double_tap(
} }
} }
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile.clone(), from: tapped_pile.clone(),
to: dest, to: dest,
count: 1, count: 1,
}); });
@@ -1571,7 +1623,7 @@ fn handle_double_tap(
let stack_index = pile_cards.cards.len() - drag.cards.len(); let stack_index = pile_cards.cards.len() - drag.cards.len();
if let Some(bottom_card) = pile_cards.cards.get(stack_index) if let Some(bottom_card) = pile_cards.cards.get(stack_index)
&& let Some((dest, count)) = && let Some((dest, count)) =
best_tableau_destination_for_stack(bottom_card, pile, &game.0, drag.cards.len()) best_tableau_destination_for_stack(bottom_card, tapped_pile, &game.0, drag.cards.len())
{ {
for (entity, ce, mut sprite) in card_sprites.iter_mut() { for (entity, ce, mut sprite) in card_sprites.iter_mut() {
if drag.cards.contains(&ce.card_id) { if drag.cards.contains(&ce.card_id) {
@@ -1582,7 +1634,7 @@ fn handle_double_tap(
} }
} }
moves.write(MoveRequestEvent { moves.write(MoveRequestEvent {
from: pile.clone(), from: tapped_pile.clone(),
to: dest, to: dest,
count, count,
}); });
@@ -1591,8 +1643,8 @@ fn handle_double_tap(
} }
rejected.write(MoveRejectedEvent { rejected.write(MoveRejectedEvent {
from: pile.clone(), from: tapped_pile.clone(),
to: pile.clone(), to: tapped_pile.clone(),
count: drag.cards.len(), count: drag.cards.len(),
}); });
} }
@@ -191,6 +191,7 @@ fn toggle_leaderboard_screen(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
mut requests: MessageReader<ToggleLeaderboardRequestEvent>, mut requests: MessageReader<ToggleLeaderboardRequestEvent>,
screens: Query<Entity, With<LeaderboardScreen>>, screens: Query<Entity, With<LeaderboardScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<LeaderboardScreen>)>,
data: Res<LeaderboardResource>, data: Res<LeaderboardResource>,
provider: Option<Res<SyncProviderResource>>, provider: Option<Res<SyncProviderResource>>,
settings: Option<Res<SettingsResource>>, settings: Option<Res<SettingsResource>>,
@@ -208,6 +209,11 @@ fn toggle_leaderboard_screen(
return; return;
} }
// Don't stack a second modal scrim over one that is already open.
if !other_modal_scrims.is_empty() {
return;
}
// Spawn the panel immediately with whatever data we have so far. // Spawn the panel immediately with whatever data we have so far.
let remote_available = provider let remote_available = provider
.as_ref() .as_ref()
+2
View File
@@ -48,6 +48,7 @@ pub mod sync_setup_plugin;
pub mod table_plugin; pub mod table_plugin;
pub mod theme; pub mod theme;
pub mod time_attack_plugin; pub mod time_attack_plugin;
pub mod touch_selection_plugin;
pub mod ui_focus; pub mod ui_focus;
pub mod ui_modal; pub mod ui_modal;
pub mod ui_theme; pub mod ui_theme;
@@ -142,6 +143,7 @@ pub use safe_area::{SafeAreaAnchoredTop, SafeAreaInsets, SafeAreaInsetsPlugin};
pub use selection_plugin::{ pub use selection_plugin::{
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState, KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
}; };
pub use touch_selection_plugin::{TouchSelectionPlugin, TouchSelectionState};
pub use settings_plugin::{ pub use settings_plugin::{
PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource, PendingWindowGeometry, SFX_STEP, SettingsChangedEvent, SettingsPlugin, SettingsResource,
SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS, SettingsScreen, WINDOW_GEOMETRY_DEBOUNCE_SECS,
+54
View File
@@ -141,6 +141,10 @@ struct HighContrastText;
#[derive(Component, Debug)] #[derive(Component, Debug)]
struct ReduceMotionText; struct ReduceMotionText;
/// Marks the `Text` node showing the current touch input mode state.
#[derive(Component, Debug)]
struct TouchInputModeText;
/// 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;
@@ -230,6 +234,10 @@ enum SettingsButton {
/// non-essential motion (card-slide animations become instant /// non-essential motion (card-slide animations become instant
/// snaps) per `design-system.md` §Accessibility (#3). /// snaps) per `design-system.md` §Accessibility (#3).
ToggleReduceMotion, ToggleReduceMotion,
/// Toggle [`Settings::touch_input_mode`] between `OneTap`
/// (auto-move on tap, default) and `TapToSelect` (first tap selects
/// a card/stack, second tap on a target pile moves it).
ToggleTouchInputMode,
/// 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::solver::try_solve`] until one is provably /// [`solitaire_core::solver::try_solve`] until one is provably
@@ -303,6 +311,7 @@ impl SettingsButton {
// run before continuing to the picker rows. // run before continuing to the picker rows.
SettingsButton::ToggleHighContrast => 61, SettingsButton::ToggleHighContrast => 61,
SettingsButton::ToggleReduceMotion => 62, SettingsButton::ToggleReduceMotion => 62,
SettingsButton::ToggleTouchInputMode => 63,
// 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,
@@ -405,11 +414,17 @@ impl Plugin for SettingsPlugin {
update_high_contrast_borders.run_if(resource_changed::<SettingsResource>), update_high_contrast_borders.run_if(resource_changed::<SettingsResource>),
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_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,
update_winnable_deals_only_text, update_winnable_deals_only_text,
update_smart_default_size_text, update_smart_default_size_text,
),
);
app.add_systems(
Update,
(
update_analytics_enabled_text, update_analytics_enabled_text,
attach_focusable_to_settings_buttons, attach_focusable_to_settings_buttons,
), ),
@@ -769,6 +784,18 @@ fn update_reduce_motion_text(
} }
} }
fn update_touch_input_mode_text(
settings: Res<SettingsResource>,
mut text_nodes: Query<&mut Text, With<TouchInputModeText>>,
) {
if !settings.is_changed() {
return;
}
for mut text in &mut text_nodes {
**text = touch_input_mode_label(&settings.0.touch_input_mode);
}
}
/// Refreshes the live "Winnable deals only" toggle value in the /// Refreshes the live "Winnable deals only" toggle value in the
/// Gameplay section whenever `SettingsResource` changes (button click, /// Gameplay section whenever `SettingsResource` changes (button click,
/// hand-edited `settings.json` reload, etc.). /// hand-edited `settings.json` reload, etc.).
@@ -1177,6 +1204,16 @@ fn handle_settings_buttons(
**t = on_off_label(settings.0.reduce_motion_mode); **t = on_off_label(settings.0.reduce_motion_mode);
} }
} }
SettingsButton::ToggleTouchInputMode => {
use solitaire_data::settings::TouchInputMode;
settings.0.touch_input_mode = match settings.0.touch_input_mode {
TouchInputMode::OneTap => TouchInputMode::TapToSelect,
TouchInputMode::TapToSelect => TouchInputMode::OneTap,
};
persist(&path, &settings.0);
changed.write(SettingsChangedEvent(settings.0.clone()));
// Text refreshed by `update_touch_input_mode_text` 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);
@@ -1311,6 +1348,14 @@ fn winnable_deals_only_label(enabled: bool) -> String {
if enabled { "ON".into() } else { "OFF".into() } if enabled { "ON".into() } else { "OFF".into() }
} }
fn touch_input_mode_label(mode: &solitaire_data::settings::TouchInputMode) -> String {
use solitaire_data::settings::TouchInputMode;
match mode {
TouchInputMode::OneTap => "One-tap".into(),
TouchInputMode::TapToSelect => "Tap to select".into(),
}
}
/// 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
@@ -1761,6 +1806,15 @@ fn spawn_settings_panel(
"Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.", "Skips card-slide animations and other non-essential motion. Cards snap instantly to their target.",
font_res, font_res,
); );
toggle_row(
body,
"Touch Input Mode",
TouchInputModeText,
touch_input_mode_label(&settings.touch_input_mode),
SettingsButton::ToggleTouchInputMode,
"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,
);
if theme_overrides_back { if theme_overrides_back {
// The active theme provides its own back; the legacy // The active theme provides its own back; the legacy
// picker has no visible effect, so we replace its // picker has no visible effect, so we replace its
+5 -1
View File
@@ -29,7 +29,7 @@ use crate::progress_plugin::ProgressResource;
use crate::resources::GameStateResource; use crate::resources::GameStateResource;
use crate::time_attack_plugin::TimeAttackResource; use crate::time_attack_plugin::TimeAttackResource;
use crate::ui_modal::{ use crate::ui_modal::{
ButtonVariant, ModalButton, ScrimDismissible, spawn_modal, spawn_modal_actions, ButtonVariant, ModalButton, ModalScrim, ScrimDismissible, spawn_modal, spawn_modal_actions,
spawn_modal_button, spawn_modal_header, spawn_modal_button, spawn_modal_header,
}; };
use crate::ui_theme::{ use crate::ui_theme::{
@@ -649,6 +649,7 @@ fn toggle_stats_screen(
latest_replay: Res<ReplayHistoryResource>, latest_replay: Res<ReplayHistoryResource>,
selected_index: Res<SelectedReplayIndex>, selected_index: Res<SelectedReplayIndex>,
screens: Query<Entity, With<StatsScreen>>, screens: Query<Entity, With<StatsScreen>>,
other_modal_scrims: Query<(), (With<ModalScrim>, Without<StatsScreen>)>,
) { ) {
let button_clicked = requests.read().count() > 0; let button_clicked = requests.read().count() > 0;
if !keys.just_pressed(KeyCode::KeyS) && !button_clicked { if !keys.just_pressed(KeyCode::KeyS) && !button_clicked {
@@ -657,6 +658,9 @@ fn toggle_stats_screen(
if let Ok(entity) = screens.single() { if let Ok(entity) = screens.single() {
commands.entity(entity).despawn(); commands.entity(entity).despawn();
} else { } else {
if !other_modal_scrims.is_empty() {
return;
}
spawn_stats_screen( spawn_stats_screen(
&mut commands, &mut commands,
&stats.0, &stats.0,
@@ -0,0 +1,234 @@
//! Touch tap-to-select input mode.
//!
//! When [`TouchInputMode::TapToSelect`] is active (set via [`crate::settings_plugin`]),
//! a single tap on a face-up card **selects** it (showing a visual highlight) instead
//! of immediately auto-moving it. A second tap on a valid destination pile performs
//! the move; a second tap on the same pile (or an invalid target) cancels silently.
//!
//! In [`TouchInputMode::OneTap`] mode this plugin is fully passive — all resources
//! default to their empty state and no highlight is ever shown.
//!
//! ## State machine
//!
//! ```text
//! Idle ──(tap face-up card)──> Selected(pile, cards)
//! ↑ │
//! │ cancel (re-tap or │ second tap on destination
//! └── StateChangedEvent) ◄──────┤ → MoveRequestEvent; back to Idle
//! │
//! └── rejected / no destination → back to Idle
//! ```
//!
//! ## Interaction with the existing auto-move flow
//!
//! [`crate::input_plugin::handle_double_tap`] is the entry point: it reads
//! [`TouchSelectionState`] and, in `TapToSelect` mode, populates it on the first
//! tap instead of firing `MoveRequestEvent`. This plugin owns the highlight visual
//! and the state-clear reactions.
use bevy::ecs::message::MessageReader;
use bevy::prelude::*;
use solitaire_core::pile::PileType;
use crate::card_plugin::CardEntity;
use crate::events::StateChangedEvent;
use crate::game_plugin::GameMutation;
use crate::layout::LayoutResource;
use crate::ui_theme::ACCENT_PRIMARY;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// State for the tap-to-select touch flow.
///
/// `selected` is `Some((source_pile, card_ids))` while the player has
/// chosen a source but not yet tapped a destination. `None` is the idle state.
///
/// `card_ids` mirrors `DragState::cards` — the bottom-to-top ordered list of
/// card ids that will be moved (1 for a single card, multiple for a face-up run).
#[derive(Resource, Debug, Default)]
pub struct TouchSelectionState {
/// Currently selected source pile and the card ids to move (bottom-to-top).
pub selected: Option<(PileType, Vec<u32>)>,
}
impl TouchSelectionState {
/// Returns `true` when a source is selected.
pub fn has_selection(&self) -> bool {
self.selected.is_some()
}
/// Takes the current selection, leaving `selected` as `None`.
pub fn take(&mut self) -> Option<(PileType, Vec<u32>)> {
self.selected.take()
}
/// Sets the current selection.
pub fn set(&mut self, pile: PileType, cards: Vec<u32>) {
self.selected = Some((pile, cards));
}
/// Clears the selection without returning it.
pub fn clear(&mut self) {
self.selected = None;
}
}
/// Marker component placed on the highlight sprite child of a selected source card.
///
/// Despawned and respawned each frame by [`update_touch_selection_highlight`] so
/// stale highlights never linger after a game-state change.
#[derive(Component)]
pub struct TouchSelectionHighlight;
// ---------------------------------------------------------------------------
// Plugin
// ---------------------------------------------------------------------------
/// Registers all resources and systems for the touch tap-to-select flow.
pub struct TouchSelectionPlugin;
impl Plugin for TouchSelectionPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<TouchSelectionState>()
.add_systems(
Update,
(
clear_touch_selection_on_state_change,
update_touch_selection_highlight,
)
.chain()
.after(GameMutation),
);
}
}
// ---------------------------------------------------------------------------
// Systems
// ---------------------------------------------------------------------------
/// Clears [`TouchSelectionState`] whenever the board changes (undo, new game,
/// won, forfeit). This prevents stale selections surviving across game resets.
pub(crate) fn clear_touch_selection_on_state_change(
mut selection: ResMut<TouchSelectionState>,
mut state_events: MessageReader<StateChangedEvent>,
) {
if state_events.read().next().is_some() {
selection.clear();
}
}
/// Maintains the `TouchSelectionHighlight` outline sprite on the selected source card.
///
/// All existing `TouchSelectionHighlight` entities are despawned each frame and
/// a new one is spawned on the top card of the selected pile (if any). This
/// matches the pattern used by `selection_plugin::update_selection_highlight`.
pub(crate) fn update_touch_selection_highlight(
mut commands: Commands,
selection: Res<TouchSelectionState>,
card_entities: Query<(Entity, &CardEntity)>,
highlights: Query<Entity, With<TouchSelectionHighlight>>,
layout: Option<Res<LayoutResource>>,
) {
// Despawn stale highlights first.
for entity in &highlights {
commands.entity(entity).despawn();
}
let Some((_, ref card_ids)) = selection.selected else {
return;
};
let Some(layout) = layout else {
return;
};
// Highlight every card in the selected stack (bottom-to-top order).
// The bottom card of the run is the most visually important anchor,
// but highlighting the whole run gives the player clear confirmation
// of how many cards are involved in the move.
let card_size = layout.0.card_size;
for &card_id in card_ids {
spawn_touch_highlight(&mut commands, &card_entities, card_id, card_size);
}
}
/// Spawns a [`TouchSelectionHighlight`] sprite as a child of the matching card entity.
fn spawn_touch_highlight(
commands: &mut Commands,
card_entities: &Query<(Entity, &CardEntity)>,
card_id: u32,
card_size: Vec2,
) {
for (entity, card_entity) in card_entities {
if card_entity.card_id == card_id {
commands.entity(entity).with_children(|b| {
b.spawn((
TouchSelectionHighlight,
Sprite {
color: ACCENT_PRIMARY.with_alpha(0.55),
custom_size: Some(card_size + Vec2::splat(6.0)),
..default()
},
Transform::from_xyz(0.0, 0.0, -0.01),
Visibility::default(),
));
});
return;
}
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn selection_state_default_is_idle() {
let state = TouchSelectionState::default();
assert!(!state.has_selection());
assert!(state.selected.is_none());
}
#[test]
fn set_and_take_roundtrip() {
let mut state = TouchSelectionState::default();
state.set(PileType::Tableau(0), vec![1, 2, 3]);
assert!(state.has_selection());
let taken = state.take();
assert!(taken.is_some());
let (pile, cards) = taken.unwrap();
assert_eq!(pile, PileType::Tableau(0));
assert_eq!(cards, vec![1, 2, 3]);
assert!(!state.has_selection());
}
#[test]
fn clear_removes_selection() {
let mut state = TouchSelectionState::default();
state.set(PileType::Waste, vec![42]);
state.clear();
assert!(!state.has_selection());
}
#[test]
fn take_on_idle_returns_none() {
let mut state = TouchSelectionState::default();
assert!(state.take().is_none());
assert!(!state.has_selection());
}
#[test]
fn set_overwrites_previous_selection() {
let mut state = TouchSelectionState::default();
state.set(PileType::Tableau(0), vec![1]);
state.set(PileType::Tableau(3), vec![7, 8]);
let (pile, cards) = state.take().unwrap();
assert_eq!(pile, PileType::Tableau(3));
assert_eq!(cards, vec![7, 8]);
}
}
+3 -1
View File
@@ -156,6 +156,8 @@ pub async fn upload(
// Update leaderboard best score/time for opted-in users when this replay // Update leaderboard best score/time for opted-in users when this replay
// beats their existing best. Only classic mode counts for the leaderboard. // beats their existing best. Only classic mode counts for the leaderboard.
// Use `received_at` (server-computed) rather than `header.recorded_at`
// (client-supplied) so clients cannot spoof the timestamp.
if header.mode == "Classic" { if header.mode == "Classic" {
sqlx::query!( sqlx::query!(
r#"UPDATE leaderboard r#"UPDATE leaderboard
@@ -170,7 +172,7 @@ pub async fn upload(
)"#, )"#,
header.final_score, header.final_score,
header.time_seconds, header.time_seconds,
header.recorded_at, received_at,
user.user_id, user.user_id,
header.final_score, header.final_score,
header.final_score, header.final_score,
+13 -3
View File
@@ -6,6 +6,7 @@
use axum::{Json, extract::State}; use axum::{Json, extract::State};
use chrono::Utc; use chrono::Utc;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use uuid::Uuid;
use solitaire_sync::{ use solitaire_sync::{
AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge, AchievementRecord, PlayerProgress, StatsSnapshot, SyncPayload, SyncResponse, merge,
@@ -142,10 +143,19 @@ pub async fn pull(
pub async fn push( pub async fn push(
State(state): State<AppState>, State(state): State<AppState>,
user: AuthenticatedUser, user: AuthenticatedUser,
Json(client_payload): Json<SyncPayload>, Json(mut client_payload): Json<SyncPayload>,
) -> Result<Json<SyncResponse>, AppError> { ) -> Result<Json<SyncResponse>, AppError> {
// Reject payloads that claim to belong to a different user. let user_uuid: Uuid = user
if client_payload.user_id.to_string() != user.user_id { .user_id
.parse()
.map_err(|_| AppError::Internal("invalid user_id UUID in JWT".into()))?;
// The desktop client always sends Uuid::nil() as a placeholder for the
// authenticated user's real ID (see build_payload docstring). Replace it
// here. Reject payloads that explicitly claim a different user's identity.
if client_payload.user_id == Uuid::nil() {
client_payload.user_id = user_uuid;
} else if client_payload.user_id != user_uuid {
return Err(AppError::BadRequest("user_id mismatch".into())); return Err(AppError::BadRequest("user_id mismatch".into()));
} }