fix(engine): swallow game-mutating hotkeys during pointer drags
U (undo), N (new game), Z (zen), D/Space (draw) and G (forfeit) were not gated on an active drag, so pressing one mid-drag fired a StateChangedEvent whose card re-sync inserted a `CardAnim` on the dragged cards — fighting `follow_drag`'s per-frame Transform writes (jittering XY, animated Z) and leaving `DragState` origin indices stale against the mutated state. `handle_keyboard_core` and `handle_keyboard_forfeit` now reuse the mutual-exclusion guard `handle_selection_keys` already documents: skip while `DragState` is non-idle, unless it is the keyboard-lift sentinel (`KEYBOARD_DRAG_TOUCH_ID`), whose lift is already dropped cleanly by `clear_selection_on_state_change`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -817,3 +817,123 @@ fn pressing_h_spawns_pending_hint_task() {
|
||||
"pressing H must spawn an async hint task",
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Pointer-drag / keyboard mutual exclusion.
|
||||
//
|
||||
// A state mutation fired while `follow_drag` is writing the dragged cards'
|
||||
// transforms every frame would make the card re-sync insert a `CardAnim`
|
||||
// on the same entities (two systems fighting over `Transform`) and leave
|
||||
// `DragState`'s origin indices stale. `handle_keyboard_core` and
|
||||
// `handle_keyboard_forfeit` therefore swallow game-mutating shortcuts
|
||||
// while a mouse or touch drag is live, mirroring `handle_selection_keys`.
|
||||
// The keyboard-lift sentinel is exempt: `clear_selection_on_state_change`
|
||||
// drops that lift cleanly when the state moves.
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
fn keyboard_core_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<UndoRequestEvent>();
|
||||
app.add_message::<NewGameRequestEvent>();
|
||||
app.add_message::<InfoToastEvent>();
|
||||
app.add_message::<DrawRequestEvent>();
|
||||
app.add_message::<StartZenRequestEvent>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<DragState>();
|
||||
app.add_systems(Update, handle_keyboard_core);
|
||||
app
|
||||
}
|
||||
|
||||
fn press_key(app: &mut App, key: KeyCode) {
|
||||
let mut input = app.world_mut().resource_mut::<ButtonInput<KeyCode>>();
|
||||
input.release(key);
|
||||
input.clear();
|
||||
input.press(key);
|
||||
}
|
||||
|
||||
fn message_count<M: Message>(app: &App) -> usize {
|
||||
let messages = app.world().resource::<Messages<M>>();
|
||||
let mut cursor = messages.get_cursor();
|
||||
cursor.read(messages).count()
|
||||
}
|
||||
|
||||
fn set_drag(app: &mut App, active_touch_id: Option<u64>) {
|
||||
let mut drag = app.world_mut().resource_mut::<DragState>();
|
||||
drag.cards = vec![Card::new(Deck::Deck1, Suit::Clubs, Rank::Two)];
|
||||
drag.committed = true;
|
||||
drag.active_touch_id = active_touch_id;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u_key_fires_undo_when_no_drag_active() {
|
||||
let mut app = keyboard_core_app();
|
||||
press_key(&mut app, KeyCode::KeyU);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
message_count::<UndoRequestEvent>(&app),
|
||||
1,
|
||||
"U with an idle DragState must fire UndoRequestEvent",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u_key_ignored_during_committed_mouse_drag() {
|
||||
let mut app = keyboard_core_app();
|
||||
set_drag(&mut app, None); // mouse drag
|
||||
press_key(&mut app, KeyCode::KeyU);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
message_count::<UndoRequestEvent>(&app),
|
||||
0,
|
||||
"U during a mouse drag must be swallowed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn draw_key_ignored_during_touch_drag() {
|
||||
let mut app = keyboard_core_app();
|
||||
set_drag(&mut app, Some(7)); // real touch id
|
||||
press_key(&mut app, KeyCode::KeyD);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
message_count::<DrawRequestEvent>(&app),
|
||||
0,
|
||||
"D during a touch drag must be swallowed",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn u_key_allowed_during_keyboard_lift() {
|
||||
let mut app = keyboard_core_app();
|
||||
set_drag(
|
||||
&mut app,
|
||||
Some(crate::selection_plugin::KEYBOARD_DRAG_TOUCH_ID),
|
||||
);
|
||||
press_key(&mut app, KeyCode::KeyU);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
message_count::<UndoRequestEvent>(&app),
|
||||
1,
|
||||
"the keyboard-lift sentinel must not block core shortcuts",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn g_key_forfeit_ignored_during_mouse_drag() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(MinimalPlugins);
|
||||
app.add_message::<ForfeitRequestEvent>();
|
||||
app.init_resource::<ButtonInput<KeyCode>>();
|
||||
app.init_resource::<DragState>();
|
||||
app.add_systems(Update, handle_keyboard_forfeit);
|
||||
|
||||
set_drag(&mut app, None);
|
||||
press_key(&mut app, KeyCode::KeyG);
|
||||
app.update();
|
||||
assert_eq!(
|
||||
message_count::<ForfeitRequestEvent>(&app),
|
||||
0,
|
||||
"G during a mouse drag must be swallowed",
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user