feat(android): add double-tap auto-move for touch input
Mirrors handle_double_click for the touch pipeline. A double-tap on a face-up card fires MoveRequestEvent to the best legal destination using the same priority order (foundation first, tableau second; stack move as priority 2 when the tapped card is a stack base). Implementation: - handle_double_tap reads TouchPhase::Ended events. When drag.active_touch_id is set and drag.committed is false, the touch ended without crossing the drag threshold = pure tap. The top card ID from drag.cards is used as the tracking key. - DOUBLE_TAP_WINDOW = 0.5s (wider than DOUBLE_CLICK_WINDOW = 0.35s; touch screens have higher input latency; pinned by a const-assert test). - System is inserted between touch_follow_drag and touch_end_drag in the .chain() so drag state is readable before touch_end_drag clears it. - touch_end_drag's uncommitted-tap cleanup path still fires after handle_double_tap — the drag.clear() + StateChangedEvent are harmless in sequence with a MoveRequestEvent already queued. 1 new test (1283 total): double_tap_window_is_wider_than_double_click_window (compile-time const assert). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -105,6 +105,7 @@ impl Plugin for InputPlugin {
|
|||||||
// Touch drag pipeline (parallel path through DragState).
|
// Touch drag pipeline (parallel path through DragState).
|
||||||
touch_start_drag,
|
touch_start_drag,
|
||||||
touch_follow_drag,
|
touch_follow_drag,
|
||||||
|
handle_double_tap, // before touch_end_drag: reads drag state pre-clear
|
||||||
touch_end_drag.before(GameMutation),
|
touch_end_drag.before(GameMutation),
|
||||||
)
|
)
|
||||||
.chain(),
|
.chain(),
|
||||||
@@ -1204,12 +1205,16 @@ fn pile_drop_rect(pile: &PileType, layout: &Layout, game: &GameState) -> (Vec2,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #27 — Double-click to auto-move
|
// Task #27 — Double-click / double-tap to auto-move
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Maximum seconds between two clicks to count as a double-click.
|
/// Maximum seconds between two clicks to count as a double-click.
|
||||||
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
const DOUBLE_CLICK_WINDOW: f32 = 0.35;
|
||||||
|
|
||||||
|
/// Maximum seconds between two taps to count as a double-tap.
|
||||||
|
/// Slightly wider than the mouse window — touch screens have higher latency.
|
||||||
|
const DOUBLE_TAP_WINDOW: f32 = 0.5;
|
||||||
|
|
||||||
/// Find the best legal destination for `card` — Foundation first, then Tableau.
|
/// Find the best legal destination for `card` — Foundation first, then Tableau.
|
||||||
///
|
///
|
||||||
/// Returns `None` if no legal move exists from the card's current location.
|
/// Returns `None` if no legal move exists from the card's current location.
|
||||||
@@ -1363,6 +1368,124 @@ fn handle_double_click(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Task #27b — Double-tap to auto-move (touch equivalent of double-click)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// System that detects double-taps on face-up cards and fires `MoveRequestEvent`
|
||||||
|
/// to the best legal destination — the touch equivalent of [`handle_double_click`].
|
||||||
|
///
|
||||||
|
/// Must run **before** `touch_end_drag` in the system chain. At
|
||||||
|
/// `TouchPhase::Ended` the drag state still holds `active_touch_id`,
|
||||||
|
/// `cards`, and `origin_pile`; once `touch_end_drag` fires those fields
|
||||||
|
/// are cleared and the tap/drag distinction is permanently lost.
|
||||||
|
///
|
||||||
|
/// A pure tap is identified by `drag.active_touch_id.is_some() &&
|
||||||
|
/// !drag.committed`: the touch began (so `touch_start_drag` populated
|
||||||
|
/// `drag`) but the drag threshold was never crossed.
|
||||||
|
///
|
||||||
|
/// Move priority matches [`handle_double_click`]:
|
||||||
|
/// 1. Move the single top card to its best foundation (or tableau).
|
||||||
|
/// 2. If no single-card move exists and the selection spans multiple
|
||||||
|
/// face-up cards, move the whole stack to the best tableau column.
|
||||||
|
/// 3. If both priorities fail, fire `MoveRejectedEvent` for audio + shake
|
||||||
|
/// feedback.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn handle_double_tap(
|
||||||
|
mut touch_events: MessageReader<TouchInput>,
|
||||||
|
paused: Option<Res<PausedResource>>,
|
||||||
|
time: Res<Time>,
|
||||||
|
drag: Res<DragState>,
|
||||||
|
game: Res<GameStateResource>,
|
||||||
|
mut last_tap: Local<HashMap<u32, f32>>,
|
||||||
|
mut moves: MessageWriter<MoveRequestEvent>,
|
||||||
|
mut rejected: MessageWriter<MoveRejectedEvent>,
|
||||||
|
) {
|
||||||
|
if paused.is_some_and(|p| p.0) {
|
||||||
|
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 };
|
||||||
|
if drag.committed {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for event in touch_events.read() {
|
||||||
|
if event.id != active_id {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match event.phase {
|
||||||
|
TouchPhase::Canceled => {
|
||||||
|
// Cancelled touch — clear any pending tap state for these cards.
|
||||||
|
for &id in &drag.cards {
|
||||||
|
last_tap.remove(&id);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
TouchPhase::Ended => {}
|
||||||
|
_ => continue,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Uncommitted touch ended = pure tap.
|
||||||
|
let Some(&top_card_id) = drag.cards.last() else { return };
|
||||||
|
let Some(ref pile) = drag.origin_pile else { return };
|
||||||
|
let Some(pile_cards) = game.0.piles.get(pile) else { return };
|
||||||
|
|
||||||
|
let Some(top_card) = pile_cards.cards.iter().find(|c| c.id == top_card_id) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
if !top_card.face_up {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let now = time.elapsed_secs();
|
||||||
|
let prev = last_tap.get(&top_card_id).copied().unwrap_or(f32::NEG_INFINITY);
|
||||||
|
|
||||||
|
if now - prev <= DOUBLE_TAP_WINDOW {
|
||||||
|
last_tap.remove(&top_card_id);
|
||||||
|
|
||||||
|
// Priority 1: move single top card.
|
||||||
|
if let Some(dest) = best_destination(top_card, &game.0) {
|
||||||
|
moves.write(MoveRequestEvent {
|
||||||
|
from: pile.clone(),
|
||||||
|
to: dest,
|
||||||
|
count: 1,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: move whole face-up stack to best tableau column.
|
||||||
|
if drag.cards.len() > 1 {
|
||||||
|
let stack_index = pile_cards.cards.len() - drag.cards.len();
|
||||||
|
if let Some(bottom_card) = pile_cards.cards.get(stack_index)
|
||||||
|
&& let Some((dest, count)) = best_tableau_destination_for_stack(
|
||||||
|
bottom_card,
|
||||||
|
pile,
|
||||||
|
&game.0,
|
||||||
|
drag.cards.len(),
|
||||||
|
)
|
||||||
|
{
|
||||||
|
moves.write(MoveRequestEvent {
|
||||||
|
from: pile.clone(),
|
||||||
|
to: dest,
|
||||||
|
count,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rejected.write(MoveRejectedEvent {
|
||||||
|
from: pile.clone(),
|
||||||
|
to: pile.clone(),
|
||||||
|
count: drag.cards.len(),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
last_tap.insert(top_card_id, now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Task #28 — Hint system helpers
|
// Task #28 — Hint system helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -2215,5 +2338,14 @@ mod tests {
|
|||||||
"pressing H must spawn an async hint task",
|
"pressing H must spawn an async hint task",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task #27b — double-tap constants
|
||||||
|
#[test]
|
||||||
|
fn double_tap_window_is_wider_than_double_click_window() {
|
||||||
|
// Compile-time check: touch needs a wider window than mouse due to
|
||||||
|
// higher input latency. `const { assert! }` catches regressions at
|
||||||
|
// build time rather than waiting for a test run.
|
||||||
|
const { assert!(DOUBLE_TAP_WINDOW > DOUBLE_CLICK_WINDOW) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user