feat(engine): keyboard-only drag-and-drop via Tab → Enter → arrows → Enter
Players can now complete an entire game without a mouse. Tab cycles
the keyboard cursor across draggable card stacks, Enter "lifts" the
focused stack into a destination-pick mode, arrow keys (or Tab)
cycle through the legal targets only, and Enter confirms the move.
Esc cancels — single-press in Lifted reverts to source-pick keeping
focus, second-press clears the source selection entirely.
A new KeyboardDragState resource models the two-mode flow without
touching SelectionState's existing source-pick contract:
Idle (Tab/Enter/auto-move via SelectionState)
Lifted {
source_pile, count, cards,
legal_destinations, pre-computed at lift time via
destination_index, can_place_on_foundation/_tableau
}
Mutual exclusion with mouse drag is sentinel-based: keyboard lift
sets DragState.active_touch_id = u64::MAX (KEYBOARD_DRAG_TOUCH_ID),
existing mouse handlers in input_plugin already short-circuit when
active_touch_id is Some, and the cleanup path only clears DragState
when the sentinel is present so the mouse path is never stomped.
Conversely keyboard input is suppressed when a real mouse/touch
drag is active.
The visual lift reuses the existing drag z-lift and shadow path so
the keyboard-lifted stack reads the same as a mouse-lifted one;
update_selection_highlight gains a green destination indicator on
the focused legal target while Lifted.
help_plugin's canonical hotkey list grows a "Keyboard drag"
section (Tab/Enter/Arrows/Esc/Space) and onboarding slide 3 picks
up a "Tab → Enter" entry so first-run players see the full path.
Seven new headless tests pin the contract: Tab cycles to first
draggable pile, Enter lifts the stack, arrow keys cycle only legal
destinations, Enter with destination fires MoveRequestEvent and
clears state, Esc reverts to source-pick, mouse-drag-active
suppresses keyboard input, double-Esc clears source selection.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -94,6 +94,17 @@ const CONTROL_SECTIONS: &[ControlSection] = &[
|
|||||||
ControlRow { keys: "Click stock", description: "Draw" },
|
ControlRow { keys: "Click stock", description: "Draw" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
ControlSection {
|
||||||
|
title: "Keyboard drag",
|
||||||
|
rows: &[
|
||||||
|
ControlRow { keys: "Tab", description: "Focus next draggable card" },
|
||||||
|
ControlRow { keys: "Enter", description: "Lift focused card (then arrows pick where)" },
|
||||||
|
ControlRow { keys: "Arrows / Tab", description: "Cycle legal destinations while lifted" },
|
||||||
|
ControlRow { keys: "Enter", description: "Drop the lifted cards on the focused pile" },
|
||||||
|
ControlRow { keys: "Esc", description: "Cancel lift (Esc again clears focus)" },
|
||||||
|
ControlRow { keys: "Space", description: "Auto-move focused card (foundation first)" },
|
||||||
|
],
|
||||||
|
},
|
||||||
ControlSection {
|
ControlSection {
|
||||||
title: "New Game",
|
title: "New Game",
|
||||||
rows: &[
|
rows: &[
|
||||||
|
|||||||
@@ -110,7 +110,9 @@ pub use settings_plugin::{
|
|||||||
};
|
};
|
||||||
pub use layout::{compute_layout, Layout, LayoutResource};
|
pub use layout::{compute_layout, Layout, LayoutResource};
|
||||||
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
pub use resources::{DragState, GameStateResource, HintCycleIndex, SettingsScrollPos, SyncStatus, SyncStatusResource};
|
||||||
pub use selection_plugin::{SelectionHighlight, SelectionPlugin, SelectionState};
|
pub use selection_plugin::{
|
||||||
|
KeyboardDragState, SelectionHighlight, SelectionPlugin, SelectionState,
|
||||||
|
};
|
||||||
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
pub use splash_plugin::{SplashAge, SplashPlugin, SplashRoot};
|
||||||
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
pub use stats_plugin::{StatsPlugin, StatsResource, StatsScreen, StatsUpdate};
|
||||||
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
pub use sync_plugin::{SyncPlugin, SyncProviderResource};
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ struct HotkeyRow {
|
|||||||
const HOTKEYS: &[HotkeyRow] = &[
|
const HOTKEYS: &[HotkeyRow] = &[
|
||||||
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
HotkeyRow { keys: "D / Space", description: "Draw from stock" },
|
||||||
HotkeyRow { keys: "U", description: "Undo last move" },
|
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: "N", description: "New Classic game" },
|
||||||
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 1–5 to pick)" },
|
HotkeyRow { keys: "M", description: "Open Mode Launcher (then 1–5 to pick)" },
|
||||||
HotkeyRow { keys: "S", description: "Stats & progression" },
|
HotkeyRow { keys: "S", description: "Stats & progression" },
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user