feat(android): long-press opens radial menu as right-click alternative

Touch screens have no right mouse button, so right-click radial was
inaccessible on Android. New system radial_open_on_long_press counts
up while a touch is held on a face-up card without crossing the drag
threshold; after 0.5 s it transitions RightClickRadialState to Active,
which the existing visual overlay and destination-ring infrastructure
then renders unchanged.

Three supporting changes to wire up the touch-driven confirm path:

- radial_track_cursor: falls back to the first active Touches position
  when cursor_world returns None, so the hover ring tracks a sliding
  held finger on Android.

- radial_handle_release_or_cancel: confirms on Touches::iter_just_released
  (finger lift) in addition to right-mouse release. Cancels on
  Touches::iter_just_canceled. No new event reader — uses the Touches
  resource which is already in scope after the track_cursor addition.

- handle_double_tap: skips when the radial is active. Guards the
  narrow edge case where the finger lifts on the exact same frame
  as the 0.5 s long-press threshold fires; prevents a spurious
  double-tap move from racing with the radial confirm.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
funman300
2026-05-11 13:23:24 -07:00
parent 76a754d8e5
commit 948864e653
3 changed files with 114 additions and 11 deletions
+16 -2
View File
@@ -119,8 +119,22 @@ rewrites required.
smaller snap-on-commit and faster perceived response.
**Remaining:** connect AVD or device and verify drag feels responsive
with no stutter; tune threshold further if needed.
- [ ] **Long-press menu.** Alternative to right-click (which doesn't
exist on touch). Wire to the existing right-click-highlight system.
- [x] **Long-press menu.** *Closed 2026-05-11.* New system
`radial_open_on_long_press` in `radial_menu.rs` counts up while a
touch is held (`drag.active_touch_id.is_some() && !drag.committed`)
and opens `RightClickRadialState::Active` after 0.5 s — the same
state the right-click path uses. Existing radial infrastructure
then handles everything:
- `radial_track_cursor` extended to fall back to the first active
touch when no cursor position is available, so sliding the held
finger moves the hover ring.
- `radial_handle_release_or_cancel` extended to confirm/cancel on
`Touches::iter_just_released()` in addition to right-mouse release.
- `handle_double_tap` skips when the radial is active (guards a
narrow edge case where the finger lifts at exactly the same frame
the 0.5 s threshold fires).
Hardware verification needed: confirm the 0.5 s hold feel, verify
sliding to a destination and lifting confirms the move.
- [ ] **HUD typography.** Reduce text sizes for `Score:`, `Moves:`,
timer so they fit cleanly in one row.
- [ ] **Orientation lock.** Set `android:screenOrientation="portrait"`
+7
View File
@@ -34,6 +34,7 @@ use solitaire_core::rules::{can_place_on_foundation, can_place_on_tableau};
use crate::card_animation::tuning::AnimationTuning;
use crate::card_animation::{CardAnimation, MotionCurve};
use crate::card_plugin::{CardEntity, HintHighlight, HintHighlightTimer, STACK_FAN_FRAC};
use crate::radial_menu::RightClickRadialState;
use crate::ui_theme::{MOTION_DRAG_REJECT_SECS, STATE_SUCCESS, STATE_WARNING};
use solitaire_core::game_state::DrawMode;
use crate::challenge_plugin::CHALLENGE_UNLOCK_LEVEL;
@@ -1413,6 +1414,7 @@ fn handle_double_click(
fn handle_double_tap(
mut touch_events: MessageReader<TouchInput>,
paused: Option<Res<PausedResource>>,
radial: Option<Res<RightClickRadialState>>,
time: Res<Time>,
drag: Res<DragState>,
game: Res<GameStateResource>,
@@ -1425,6 +1427,11 @@ fn handle_double_tap(
if paused.is_some_and(|p| p.0) {
return;
}
// Long-press opened the radial in this frame — let radial_handle_release_or_cancel
// own the finger-lift event instead.
if radial.is_some_and(|r| r.is_active()) {
return;
}
// Only active when a touch is tracked and hasn't crossed the drag threshold.
let Some(active_id) = drag.active_touch_id else { return };
+91 -9
View File
@@ -42,6 +42,7 @@
//! real `PrimaryWindow` / camera, since `MinimalPlugins` provides
//! neither.
use bevy::input::touch::Touches;
use bevy::input::ButtonInput;
use bevy::math::Vec2;
use bevy::prelude::*;
@@ -59,6 +60,11 @@ use crate::resources::{DragState, GameStateResource};
use crate::settings_plugin::SettingsResource;
use crate::ui_theme::{ACCENT_PRIMARY, BORDER_STRONG, BORDER_SUBTLE, BORDER_SUBTLE_HC, STATE_SUCCESS};
/// Seconds a finger must be held on a face-up card (without crossing the
/// drag threshold) before the radial menu opens. Matches Android's long-press
/// gesture recogniser default.
const LONG_PRESS_SECS: f32 = 0.5;
/// Sprite-space `Transform.z` for radial-menu overlay sprites.
///
/// One rung above [`crate::ui_theme::Z_DROP_OVERLAY`] (`50.0`) so the radial icons render
@@ -181,6 +187,7 @@ impl Plugin for RadialMenuPlugin {
Update,
(
radial_open_on_right_click,
radial_open_on_long_press,
radial_track_cursor,
radial_handle_release_or_cancel,
radial_redraw_overlay,
@@ -446,6 +453,68 @@ fn radial_open_on_right_click(
};
}
/// Opens the radial menu after a sustained touch hold on a face-up card.
///
/// Counts up while the touch is down, the drag threshold has not been
/// crossed, and the radial is not yet active. Fires after
/// [`LONG_PRESS_SECS`] (0.5 s). The timer resets whenever these
/// conditions are not met, so lifting, committing a drag, or the radial
/// already being open all clear it cleanly.
#[allow(clippy::too_many_arguments)]
fn radial_open_on_long_press(
time: Res<Time>,
mut hold_timer: Local<f32>,
drag: Res<DragState>,
paused: Option<Res<PausedResource>>,
touches: Option<Res<Touches>>,
cameras: Query<(&Camera, &GlobalTransform)>,
layout: Option<Res<LayoutResource>>,
game: Option<Res<GameStateResource>>,
mut state: ResMut<RightClickRadialState>,
) {
// Guard: only count while a touch is down, uncommitted, and radial is idle.
let active_id = drag.active_touch_id;
if active_id.is_none() || drag.committed || state.is_active() || paused.is_some_and(|p| p.0) {
*hold_timer = 0.0;
return;
}
*hold_timer += time.delta_secs();
if *hold_timer < LONG_PRESS_SECS {
return;
}
*hold_timer = 0.0;
// Resolve current touch world position.
let Some(touches) = touches else { return };
let Some(touch) = touches.iter().find(|t| t.id() == active_id.unwrap()) else {
return;
};
let Some((camera, cam_xf)) = cameras.single().ok() else { return };
let Some(world) = camera.viewport_to_world_2d(cam_xf, touch.position()).ok() else {
return;
};
let Some(layout) = layout else { return };
let Some(game) = game else { return };
let Some((source_pile, card)) = find_top_face_up_card_at(world, &game.0, &layout.0) else {
return;
};
let dests = legal_destinations_for_card(&card, &source_pile, &game.0);
if dests.is_empty() {
return;
}
let legal_destinations = build_radial_destinations(world, dests);
*state = RightClickRadialState::Active {
source_pile,
count: 1,
cards: vec![card.id],
legal_destinations,
centre: world,
hovered_index: None,
};
}
/// Each frame while `Active`, updates `hovered_index` based on the
/// current cursor position. Cheap — just re-runs hit-testing against
/// the precomputed anchors. The overlay redraw system reads this index
@@ -454,6 +523,7 @@ fn radial_track_cursor(
cursor_override: Option<Res<RadialCursorOverride>>,
windows: Query<&Window, With<PrimaryWindow>>,
cameras: Query<(&Camera, &GlobalTransform)>,
touches: Option<Res<Touches>>,
mut state: ResMut<RightClickRadialState>,
) {
let RightClickRadialState::Active {
@@ -464,21 +534,28 @@ fn radial_track_cursor(
else {
return;
};
let Some(world) = cursor_world(cursor_override.as_ref(), &windows, &cameras) else {
return;
};
// Cursor first (mouse / test override); fall back to first active touch
// so the player can slide their held finger over radial icons on Android.
let world = cursor_world(cursor_override.as_ref(), &windows, &cameras).or_else(|| {
let (camera, cam_xf) = cameras.single().ok()?;
let touch_pos = touches.as_ref()?.iter().next()?.position();
camera.viewport_to_world_2d(cam_xf, touch_pos).ok()
});
let Some(world) = world else { return };
let anchors: Vec<Vec2> = legal_destinations.iter().map(|(_, a)| *a).collect();
*hovered_index = radial_hovered_index(world, &anchors);
}
/// Handles three exit conditions while `Active`:
/// Handles exit conditions while `Active`:
/// 1. Right-mouse release → confirm if hovering, otherwise cancel.
/// 2. `Escape` → cancel.
/// 3. Left-mouse press → cancel (keeps the existing drag pipeline clean).
/// 2. Touch lift (`Touches::iter_just_released`) → confirm if hovering, cancel otherwise.
/// 3. `Escape` → cancel.
/// 4. Left-mouse press → cancel (keeps the existing drag pipeline clean).
#[allow(clippy::too_many_arguments)]
fn radial_handle_release_or_cancel(
buttons: Option<Res<ButtonInput<MouseButton>>>,
keys: Option<Res<ButtonInput<KeyCode>>>,
touches: Option<Res<Touches>>,
mut state: ResMut<RightClickRadialState>,
mut moves: MessageWriter<MoveRequestEvent>,
) {
@@ -495,13 +572,18 @@ fn radial_handle_release_or_cancel(
let left_pressed = buttons
.as_ref()
.is_some_and(|b| b.just_pressed(MouseButton::Left));
// Finger lift: any touch that ended or was cancelled this frame.
let touch_ended = touches.as_ref().is_some_and(|t| {
t.iter_just_released().next().is_some() || t.iter_just_canceled().next().is_some()
});
if !escape_pressed && !right_released && !left_pressed {
if !escape_pressed && !right_released && !left_pressed && !touch_ended {
return;
}
// On confirm, fire a MoveRequestEvent. On any other exit, just clear.
if right_released
// On confirm (right-release or touch-lift while hovering), fire a move.
let confirm = right_released || touch_ended;
if confirm
&& let RightClickRadialState::Active {
source_pile,
count,