1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use crate::input::{UiKeyCode, UiKeyEvent, UiKeyEventKind};
5use flatland_client_lib::{ClientKeyBindings, RotationEditorMode};
6
7use crate::keymap::{combat_action_for_key, CombatKeyAction};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ActiveOverlay {
12 #[default]
13 None,
14 Loadout,
15 RotationEditor(RotationEditorMode),
16 Stats,
18}
19
20pub const MOVEMENT_IDLE_TIMEOUT: Duration = Duration::from_millis(750);
24const CHORD_RELEASE_GRACE: Duration = Duration::from_millis(220);
27const SPRINT_SHIFT_REFRESH: Duration = Duration::from_millis(700);
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
30enum DirectionKey {
31 Up,
32 Down,
33 Left,
34 Right,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
38enum VerticalKey {
39 Up,
40 Down,
41}
42
43fn direction_from_key(code: UiKeyCode) -> Option<DirectionKey> {
44 match code {
45 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
46 'w' => Some(DirectionKey::Up),
47 's' => Some(DirectionKey::Down),
48 'a' => Some(DirectionKey::Left),
49 'd' => Some(DirectionKey::Right),
50 _ => None,
51 },
52 UiKeyCode::Up => Some(DirectionKey::Up),
53 UiKeyCode::Down => Some(DirectionKey::Down),
54 UiKeyCode::Left => Some(DirectionKey::Left),
55 UiKeyCode::Right => Some(DirectionKey::Right),
56 _ => None,
57 }
58}
59
60fn direction_axes(dir: DirectionKey) -> (f32, f32) {
61 match dir {
62 DirectionKey::Up => (1.0, 0.0),
63 DirectionKey::Down => (-1.0, 0.0),
64 DirectionKey::Left => (0.0, -1.0),
65 DirectionKey::Right => (0.0, 1.0),
66 }
67}
68
69fn is_shift_key(code: UiKeyCode) -> bool {
70 matches!(code, UiKeyCode::ShiftLeft | UiKeyCode::ShiftRight)
71}
72
73#[derive(Debug, Default)]
74pub struct MapTargetState {
75 pub active: bool,
76 pub cursor_x: f32,
77 pub cursor_y: f32,
78}
79
80impl MapTargetState {
81 pub fn activate_at(&mut self, x: f32, y: f32) {
82 self.active = true;
83 self.cursor_x = x;
84 self.cursor_y = y;
85 }
86
87 pub fn deactivate(&mut self) {
88 self.active = false;
89 }
90
91 pub fn nudge(&mut self, dx: i32, dy: i32, max_x: f32, max_y: f32) {
92 self.cursor_x = (self.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
93 self.cursor_y = (self.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
94 }
95}
96
97#[derive(Debug)]
102pub struct MovementState {
103 held_dirs: HashMap<DirectionKey, Instant>,
104 vertical_held: HashMap<VerticalKey, Instant>,
105 shift_held: bool,
106 sprint_until: Instant,
107 sprint_toggle: bool,
108 last_forward: f32,
109 last_strafe: f32,
110 chord_formed_at: Option<Instant>,
112 pub keys: ClientKeyBindings,
113}
114
115impl Default for MovementState {
116 fn default() -> Self {
117 Self {
118 held_dirs: HashMap::new(),
119 vertical_held: HashMap::new(),
120 shift_held: false,
121 sprint_until: Instant::now(),
122 sprint_toggle: false,
123 last_forward: 0.0,
124 last_strafe: 0.0,
125 chord_formed_at: None,
126 keys: ClientKeyBindings::default(),
127 }
128 }
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq)]
132pub enum MovementInput {
133 Stop,
134 Forward,
135 Back,
136 Left,
137 Right,
138 ForwardLeft,
139 ForwardRight,
140 BackLeft,
141 BackRight,
142}
143
144impl MovementInput {
145 pub fn components(self) -> (f32, f32) {
146 let (mut forward, mut strafe): (f32, f32) = match self {
147 Self::Stop => (0.0, 0.0),
148 Self::Forward => (1.0, 0.0),
149 Self::Back => (-1.0, 0.0),
150 Self::Left => (0.0, -1.0),
151 Self::Right => (0.0, 1.0),
152 Self::ForwardLeft => (1.0, -1.0),
153 Self::ForwardRight => (1.0, 1.0),
154 Self::BackLeft => (-1.0, -1.0),
155 Self::BackRight => (-1.0, 1.0),
156 };
157
158 let len = (forward * forward + strafe * strafe).sqrt();
159 if len > 1.0 {
160 forward /= len;
161 strafe /= len;
162 }
163 (forward, strafe)
164 }
165
166 pub fn label(self) -> &'static str {
167 match self {
168 Self::Stop => "stop",
169 Self::Forward => "up",
170 Self::Back => "down",
171 Self::Left => "left",
172 Self::Right => "right",
173 Self::ForwardLeft => "up-left",
174 Self::ForwardRight => "up-right",
175 Self::BackLeft => "down-left",
176 Self::BackRight => "down-right",
177 }
178 }
179}
180
181#[derive(Debug, Clone, Copy, PartialEq)]
182pub enum InputAction {
183 Quit,
184 Harvest,
185 Pickup,
186 Craft,
187 Interact,
188 TestDamage,
189 CycleCombatTarget {
190 reverse: bool,
191 },
192 CycleCombatTargetT2 {
193 reverse: bool,
194 },
195 AdvanceRotationT1,
196 AdvanceRotationT2,
197 ToggleAutoT1,
198 ToggleAutoT2,
199 ToggleLoadout,
200 ToggleRotationEditor,
201 LoadoutAssignT1,
202 LoadoutAssignT2,
203 LoadoutMenuUp,
204 LoadoutMenuDown,
205 LoadoutHotbarPrev,
206 LoadoutHotbarNext,
207 LoadoutBindHotbar,
208 LoadoutClearHotbar,
209 LoadoutToggleFocus,
210 RotationEditorListUp,
211 RotationEditorListDown,
212 RotationEditorEdit,
213 RotationEditorNew,
214 RotationEditorDelete,
215 RotationEditorBack,
216 RotationEditorAddAbility,
217 RotationEditorRemoveAbility,
218 RotationEditorMoveAbilityUp,
219 RotationEditorMoveAbilityDown,
220 RotationEditorAbilityUp,
221 RotationEditorAbilityDown,
222 RotationEditorPickerUp,
223 RotationEditorPickerDown,
224 RotationEditorPickAbility,
225 RotationEditorRename,
226 RotationEditorConfirmLabel,
227 RotationEditorLabelBackspace,
228 RotationEditorLabelChar(char),
229 RotationEditorSave,
230 CloseOverlay,
231 ClearCombatTarget,
232 ToggleStats,
233 ToggleEquip,
234 CycleCharacterSheetTab,
235 LedgerPeriodDigit(char),
236 ToggleInventory,
237 ToggleKeychain,
238 ToggleQuestMenu,
239 QuestMenuUp,
240 QuestMenuDown,
241 QuestWithdraw,
242 ToggleWorkersMenu,
243 ToggleHelp,
244 CycleHudView,
245 StartChat {
246 whisper: bool,
247 },
248 SubmitChat,
249 CancelChat,
250 Dodge,
251 Lunge,
252 DirectionalJump {
254 forward: f32,
255 strafe: f32,
256 },
257 ToggleBlock,
258 ToggleSprintMode,
259 ToggleMapTarget,
260 ConfirmMapTarget,
261 CancelMapTarget,
262 MapTargetNudge {
263 dx: i32,
264 dy: i32,
265 },
266 CancelAutoNav,
267 StopMovement,
269 UseWorld,
271 CastHotbar {
272 slot: u8,
273 },
274 ClearCombatTargetT2,
275 None,
276}
277
278fn rotation_editor_action(key: UiKeyEvent, mode: RotationEditorMode) -> Option<InputAction> {
279 let shift = key.modifiers.shift;
280 Some(match mode {
281 RotationEditorMode::List => match key.code {
282 UiKeyCode::Esc => InputAction::CloseOverlay,
283 UiKeyCode::Up => InputAction::RotationEditorListUp,
284 UiKeyCode::Down => InputAction::RotationEditorListDown,
285 UiKeyCode::Enter => InputAction::RotationEditorEdit,
286 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
287 'n' => InputAction::RotationEditorNew,
288 'd' => InputAction::RotationEditorDelete,
289 _ => return None,
290 },
291 _ => return None,
292 },
293 RotationEditorMode::EditSequence => match key.code {
294 UiKeyCode::Up if shift => InputAction::RotationEditorMoveAbilityUp,
295 UiKeyCode::Down if shift => InputAction::RotationEditorMoveAbilityDown,
296 UiKeyCode::Esc => InputAction::RotationEditorBack,
297 UiKeyCode::Char('[') | UiKeyCode::Char(';') => InputAction::RotationEditorMoveAbilityUp,
298 UiKeyCode::Char(']') | UiKeyCode::Char('/') | UiKeyCode::Char('\\') => {
299 InputAction::RotationEditorMoveAbilityDown
300 }
301 UiKeyCode::Up => InputAction::RotationEditorAbilityUp,
302 UiKeyCode::Down => InputAction::RotationEditorAbilityDown,
303 UiKeyCode::Delete => InputAction::RotationEditorRemoveAbility,
304 UiKeyCode::Enter => InputAction::RotationEditorSave,
305 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
306 'a' => InputAction::RotationEditorAddAbility,
307 'x' => InputAction::RotationEditorRemoveAbility,
308 'r' => InputAction::RotationEditorRename,
309 's' => InputAction::RotationEditorSave,
310 _ => return None,
311 },
312 _ => return None,
313 },
314 RotationEditorMode::PickAbility => match key.code {
315 UiKeyCode::Esc => InputAction::RotationEditorBack,
316 UiKeyCode::Up => InputAction::RotationEditorPickerUp,
317 UiKeyCode::Down => InputAction::RotationEditorPickerDown,
318 UiKeyCode::Enter => InputAction::RotationEditorPickAbility,
319 _ => return None,
320 },
321 RotationEditorMode::EditLabel => match key.code {
322 UiKeyCode::Esc => InputAction::RotationEditorBack,
323 UiKeyCode::Enter => InputAction::RotationEditorConfirmLabel,
324 UiKeyCode::Backspace => InputAction::RotationEditorLabelBackspace,
325 UiKeyCode::Char(c) if !key.modifiers.control => InputAction::RotationEditorLabelChar(c),
326 _ => return None,
327 },
328 })
329}
330
331impl MovementState {
332 pub fn with_keys(keys: ClientKeyBindings) -> Self {
333 Self {
334 keys,
335 ..Default::default()
336 }
337 }
338
339 pub fn idle_timeout(&self) -> Duration {
340 MOVEMENT_IDLE_TIMEOUT
341 }
342
343 fn touch_dir(&mut self, dir: DirectionKey) {
347 let now = Instant::now();
348 let idle = self.idle_timeout();
349 let joining_chord = !self.held_dirs.contains_key(&dir)
350 && self.held_dirs.values().any(|at| at.elapsed() < idle);
351 if joining_chord {
352 self.chord_formed_at = Some(now);
353 }
354 self.held_dirs.insert(dir, now);
355 for (other, at) in self.held_dirs.iter_mut() {
356 if *other != dir && at.elapsed() < idle {
357 *at = now;
358 }
359 }
360 }
361
362 fn release_dir(&mut self, dir: DirectionKey) {
363 self.held_dirs.remove(&dir);
364 }
365
366 fn refresh_sprint(&mut self, key: &UiKeyEvent) {
367 let shift = key.modifiers.shift
368 || matches!(
369 key.code,
370 UiKeyCode::Char(c)
371 if c.is_ascii_uppercase() && direction_from_key(key.code).is_some()
372 );
373 if shift {
374 self.sprint_until = Instant::now() + SPRINT_SHIFT_REFRESH;
375 }
376 }
377
378 fn dir_active(&self, dir: DirectionKey) -> bool {
379 let idle = self.idle_timeout();
380 self.held_dirs
381 .get(&dir)
382 .is_some_and(|at| at.elapsed() < idle)
383 }
384
385 fn vertical_active(&self, key: VerticalKey) -> bool {
386 let idle = self.idle_timeout();
387 self.vertical_held
388 .get(&key)
389 .is_some_and(|at| at.elapsed() < idle)
390 }
391
392 pub fn reset(&mut self) {
393 self.held_dirs.clear();
394 self.vertical_held.clear();
395 self.shift_held = false;
396 self.sprint_until = Instant::now();
397 self.chord_formed_at = None;
398 }
399
400 pub fn sprint_mode(&self) -> bool {
401 self.sprint_toggle
402 }
403
404 pub fn toggle_sprint_mode(&mut self) {
405 self.sprint_toggle = !self.sprint_toggle;
406 }
407
408 pub fn vertical_axis(&self) -> f32 {
409 let up = self.vertical_active(VerticalKey::Up);
410 let down = self.vertical_active(VerticalKey::Down);
411 match (up, down) {
412 (true, false) => 1.0,
413 (false, true) => -1.0,
414 _ => 0.0,
415 }
416 }
417
418 pub fn sprinting(&self) -> bool {
419 self.sprint_toggle || self.shift_held || Instant::now() < self.sprint_until
420 }
421
422 pub fn last_move_axes(&self) -> (f32, f32) {
423 (self.last_forward, self.last_strafe)
424 }
425
426 fn remember_movement(&mut self) {
427 let (forward, strafe) = self.current().components();
428 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
429 self.last_forward = forward;
430 self.last_strafe = strafe;
431 }
432 }
433
434 pub fn current(&self) -> MovementInput {
435 let up = self.dir_active(DirectionKey::Up);
436 let down = self.dir_active(DirectionKey::Down);
437 let left = self.dir_active(DirectionKey::Left);
438 let right = self.dir_active(DirectionKey::Right);
439
440 let forward = match (up, down) {
442 (true, false) => 1,
443 (false, true) => -1,
444 _ => 0,
445 };
446 let strafe = match (left, right) {
447 (true, false) => -1,
448 (false, true) => 1,
449 _ => 0,
450 };
451 match (forward, strafe) {
452 (1, 0) => MovementInput::Forward,
453 (-1, 0) => MovementInput::Back,
454 (0, -1) => MovementInput::Left,
455 (0, 1) => MovementInput::Right,
456 (1, -1) => MovementInput::ForwardLeft,
457 (1, 1) => MovementInput::ForwardRight,
458 (-1, -1) => MovementInput::BackLeft,
459 (-1, 1) => MovementInput::BackRight,
460 _ => MovementInput::Stop,
461 }
462 }
463
464 pub fn apply_ui_key(
465 &mut self,
466 key: UiKeyEvent,
467 overlay: ActiveOverlay,
468 map_target_active: bool,
469 ) -> InputAction {
470 if key.kind == UiKeyEventKind::Press
471 && key.modifiers.control
472 && matches!(key.code, UiKeyCode::Char('q') | UiKeyCode::Char('c'))
473 {
474 return InputAction::Quit;
475 }
476
477 if overlay != ActiveOverlay::None {
478 if key.kind == UiKeyEventKind::Release {
480 if let Some(dir) = direction_from_key(key.code) {
481 self.release_dir(dir);
482 self.remember_movement();
483 }
484 match key.code {
485 UiKeyCode::Char('u') => {
486 self.vertical_held.remove(&VerticalKey::Up);
487 }
488 UiKeyCode::Char('j') => {
489 self.vertical_held.remove(&VerticalKey::Down);
490 }
491 _ => {}
492 }
493 return InputAction::None;
494 }
495 if key.kind == UiKeyEventKind::Press {
496 let action = match overlay {
497 ActiveOverlay::Loadout => match key.code {
498 UiKeyCode::Esc => Some(InputAction::CloseOverlay),
499 UiKeyCode::Up => Some(InputAction::LoadoutMenuUp),
500 UiKeyCode::Down => Some(InputAction::LoadoutMenuDown),
501 UiKeyCode::Left | UiKeyCode::Char('[') => {
502 Some(InputAction::LoadoutHotbarPrev)
503 }
504 UiKeyCode::Right | UiKeyCode::Char(']') => {
505 Some(InputAction::LoadoutHotbarNext)
506 }
507 UiKeyCode::Tab | UiKeyCode::BackTab => {
508 Some(InputAction::LoadoutToggleFocus)
509 }
510 UiKeyCode::Enter => Some(InputAction::LoadoutBindHotbar),
511 UiKeyCode::Delete | UiKeyCode::Backspace => {
512 Some(InputAction::LoadoutClearHotbar)
513 }
514 UiKeyCode::Char('1') => Some(InputAction::LoadoutAssignT1),
515 UiKeyCode::Char('2') => Some(InputAction::LoadoutAssignT2),
516 _ => None,
517 },
518 ActiveOverlay::RotationEditor(mode) => rotation_editor_action(key, mode),
519 ActiveOverlay::Stats => match key.code {
520 UiKeyCode::Esc => Some(InputAction::ToggleStats),
521 UiKeyCode::Tab | UiKeyCode::BackTab => {
522 Some(InputAction::CycleCharacterSheetTab)
523 }
524 UiKeyCode::Char('i') | UiKeyCode::Char('I') => {
525 Some(InputAction::ToggleStats)
526 }
527 UiKeyCode::Char(c) if matches!(c, '1' | '2' | '3' | '4') => {
528 Some(InputAction::LedgerPeriodDigit(c))
529 }
530 _ => None,
531 },
532 ActiveOverlay::None => None,
533 };
534 if let Some(action) = action {
535 return action;
536 }
537 if let Some(action) = combat_action_for_key(&key, &self.keys) {
538 return match action {
539 CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
540 CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
541 _ => InputAction::None,
542 };
543 }
544 }
545 return InputAction::None;
546 }
547
548 if map_target_active {
550 if matches!(key.kind, UiKeyEventKind::Press | UiKeyEventKind::Repeat) {
551 return match key.code {
552 UiKeyCode::Esc => InputAction::CancelMapTarget,
553 UiKeyCode::Enter => InputAction::ConfirmMapTarget,
554 UiKeyCode::Char('m') => InputAction::CancelMapTarget,
555 UiKeyCode::Char(' ') => InputAction::StopMovement,
556 UiKeyCode::Char('w') | UiKeyCode::Up => {
557 InputAction::MapTargetNudge { dx: 0, dy: 1 }
558 }
559 UiKeyCode::Char('s') | UiKeyCode::Down => {
560 InputAction::MapTargetNudge { dx: 0, dy: -1 }
561 }
562 UiKeyCode::Char('a') | UiKeyCode::Left => {
563 InputAction::MapTargetNudge { dx: -1, dy: 0 }
564 }
565 UiKeyCode::Char('d') | UiKeyCode::Right => {
566 InputAction::MapTargetNudge { dx: 1, dy: 0 }
567 }
568 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
569 'w' => InputAction::MapTargetNudge { dx: 0, dy: 1 },
570 's' => InputAction::MapTargetNudge { dx: 0, dy: -1 },
571 'a' => InputAction::MapTargetNudge { dx: -1, dy: 0 },
572 'd' => InputAction::MapTargetNudge { dx: 1, dy: 0 },
573 _ => InputAction::None,
574 },
575 _ => InputAction::None,
576 };
577 }
578 return InputAction::None;
579 }
580
581 if key.kind == UiKeyEventKind::Press {
582 if let Some(action) = combat_action_for_key(&key, &self.keys) {
584 return match action {
585 CombatKeyAction::CycleTargetT1 { reverse } => {
586 InputAction::CycleCombatTarget { reverse }
587 }
588 CombatKeyAction::CycleTargetT2 { reverse } => {
589 InputAction::CycleCombatTargetT2 { reverse }
590 }
591 CombatKeyAction::ToggleAutoT1 => InputAction::ToggleAutoT1,
592 CombatKeyAction::ToggleAutoT2 => InputAction::ToggleAutoT2,
593 CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
594 CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
595 CombatKeyAction::Dodge => InputAction::Dodge,
596 CombatKeyAction::Lunge => InputAction::Lunge,
597 CombatKeyAction::ToggleBlock => InputAction::ToggleBlock,
598 CombatKeyAction::ClearTargetT1 => InputAction::ClearCombatTarget,
599 CombatKeyAction::ClearTargetT2 => InputAction::ClearCombatTargetT2,
600 CombatKeyAction::Hotbar(slot) => InputAction::CastHotbar { slot },
601 };
602 }
603
604 match key.code {
605 UiKeyCode::Tab => return InputAction::CycleCharacterSheetTab,
606 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
607 'f' => return InputAction::UseWorld,
608 'n' if !key.modifiers.control => return InputAction::Craft,
609 ',' => return InputAction::ToggleKeychain,
610 'i' => return InputAction::ToggleStats,
611 'p' if !key.modifiers.control => return InputAction::ToggleEquip,
612 '1' | '2' | '3' | '4' => {
613 return InputAction::LedgerPeriodDigit(c.to_ascii_lowercase())
614 }
615 'b' if !key.modifiers.control => return InputAction::ToggleInventory,
616 'v' if !key.modifiers.control => return InputAction::ToggleQuestMenu,
617 'h' if !key.modifiers.control => return InputAction::ToggleWorkersMenu,
618 '?' | '/' => return InputAction::ToggleHelp,
619 '.' => return InputAction::CycleHudView,
620 '-' => return InputAction::TestDamage,
621 't' => return InputAction::StartChat { whisper: false },
622 'g' => return InputAction::StartChat { whisper: true },
623 'x' => return InputAction::ToggleSprintMode,
624 'm' => return InputAction::ToggleMapTarget,
625 ' ' => {
626 self.reset();
627 return InputAction::StopMovement;
628 }
629 _ => {}
630 },
631 UiKeyCode::Enter => return InputAction::SubmitChat,
632 _ => {}
633 }
634 }
635
636 if is_shift_key(key.code) {
637 match key.kind {
638 UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
639 self.shift_held = true;
640 self.sprint_until = Instant::now() + Duration::from_secs(30);
641 }
642 UiKeyEventKind::Release => {
643 self.shift_held = false;
644 if !self.sprint_toggle {
645 self.sprint_until = Instant::now();
646 }
647 }
648 }
649 return InputAction::None;
650 }
651
652 let vertical = match key.code {
653 UiKeyCode::Char('u') => Some(VerticalKey::Up),
654 UiKeyCode::Char('j') => Some(VerticalKey::Down),
655 _ => None,
656 };
657 if let Some(v) = vertical {
658 match key.kind {
659 UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
660 self.vertical_held.insert(v, Instant::now());
661 }
662 UiKeyEventKind::Release => {
663 self.vertical_held.remove(&v);
664 }
665 }
666 return InputAction::None;
667 }
668
669 let Some(dir) = direction_from_key(key.code) else {
670 return InputAction::None;
671 };
672
673 if key.modifiers.alt && key.kind == UiKeyEventKind::Press {
674 let (forward, strafe) = direction_axes(dir);
675 return InputAction::DirectionalJump { forward, strafe };
676 }
677
678 match key.kind {
679 UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
680 self.touch_dir(dir);
681 self.refresh_sprint(&key);
682 self.remember_movement();
683 InputAction::None
684 }
685 UiKeyEventKind::Release => {
686 let was_held = self.held_dirs.contains_key(&dir);
687 let in_chord_grace = self
688 .chord_formed_at
689 .is_some_and(|t| t.elapsed() < CHORD_RELEASE_GRACE);
690 if was_held && !in_chord_grace {
693 self.release_dir(dir);
694 if self.held_dirs.len() < 2 {
695 self.chord_formed_at = None;
696 }
697 self.remember_movement();
698 if self.current() == MovementInput::Stop {
699 return InputAction::StopMovement;
700 }
701 }
702 InputAction::None
703 }
704 }
705 }
706
707 pub fn expire_idle(&mut self, idle: Duration) {
710 self.held_dirs.retain(|_, at| at.elapsed() < idle);
711 self.vertical_held.retain(|_, at| at.elapsed() < idle);
712 if self.held_dirs.len() < 2 {
713 self.chord_formed_at = None;
714 }
715 }
716
717 pub fn sync_physical_holds(
721 &mut self,
722 forward: bool,
723 back: bool,
724 left: bool,
725 right: bool,
726 vertical_up: bool,
727 vertical_down: bool,
728 shift: bool,
729 ) {
730 let now = Instant::now();
731 for (dir, held) in [
732 (DirectionKey::Up, forward),
733 (DirectionKey::Down, back),
734 (DirectionKey::Left, left),
735 (DirectionKey::Right, right),
736 ] {
737 if held {
738 self.held_dirs.insert(dir, now);
739 } else {
740 self.held_dirs.remove(&dir);
741 }
742 }
743 for (key, held) in [
744 (VerticalKey::Up, vertical_up),
745 (VerticalKey::Down, vertical_down),
746 ] {
747 if held {
748 self.vertical_held.insert(key, now);
749 } else {
750 self.vertical_held.remove(&key);
751 }
752 }
753 self.shift_held = shift;
754 if shift {
755 self.sprint_until = now + SPRINT_SHIFT_REFRESH;
756 }
757 if self.held_dirs.len() < 2 {
758 self.chord_formed_at = None;
759 }
760 self.remember_movement();
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::input::{UiKeyCode, UiKeyEventKind, UiKeyModifiers};
768
769 fn key(code: UiKeyCode, kind: UiKeyEventKind) -> UiKeyEvent {
770 UiKeyEvent {
771 code,
772 modifiers: UiKeyModifiers::default(),
773 kind,
774 }
775 }
776
777 #[test]
778 fn combat_keys_on_left_hand() {
779 let mut state = MovementState::default();
780 assert_eq!(
781 state.apply_ui_key(
782 key(UiKeyCode::Char('c'), UiKeyEventKind::Press),
783 ActiveOverlay::None,
784 false
785 ),
786 InputAction::Dodge
787 );
788 assert_eq!(
789 state.apply_ui_key(
790 key(UiKeyCode::Char('q'), UiKeyEventKind::Press),
791 ActiveOverlay::None,
792 false
793 ),
794 InputAction::ToggleBlock
795 );
796 assert_eq!(
797 state.apply_ui_key(
798 UiKeyEvent {
799 code: UiKeyCode::Char(' '),
800 modifiers: UiKeyModifiers {
801 shift: true,
802 control: false,
803 alt: false
804 },
805 kind: UiKeyEventKind::Press
806 },
807 ActiveOverlay::None,
808 false
809 ),
810 InputAction::Lunge
811 );
812 assert_eq!(
814 state.apply_ui_key(
815 key(UiKeyCode::Char('e'), UiKeyEventKind::Press),
816 ActiveOverlay::None,
817 false
818 ),
819 InputAction::None
820 );
821 assert_eq!(state.current(), MovementInput::Stop);
822 }
823
824 #[test]
825 fn world_and_utility_row_bindings() {
826 let mut state = MovementState::default();
827 assert_eq!(
828 state.apply_ui_key(
829 key(UiKeyCode::Char('f'), UiKeyEventKind::Press),
830 ActiveOverlay::None,
831 false
832 ),
833 InputAction::UseWorld
834 );
835 assert_eq!(
836 state.apply_ui_key(
837 key(UiKeyCode::Char('n'), UiKeyEventKind::Press),
838 ActiveOverlay::None,
839 false
840 ),
841 InputAction::Craft
842 );
843 assert_eq!(
844 state.apply_ui_key(
845 key(UiKeyCode::Char(','), UiKeyEventKind::Press),
846 ActiveOverlay::None,
847 false
848 ),
849 InputAction::ToggleKeychain
850 );
851 assert_eq!(
852 state.apply_ui_key(
853 key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
854 ActiveOverlay::None,
855 false
856 ),
857 InputAction::ToggleHelp
858 );
859 assert_eq!(
860 state.apply_ui_key(
861 key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
862 ActiveOverlay::None,
863 false
864 ),
865 InputAction::CastHotbar { slot: 1 }
866 );
867 }
868
869 #[test]
870 fn map_target_repeat_nudges_cursor() {
871 let mut state = MovementState::default();
872 assert_eq!(
873 state.apply_ui_key(
874 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
875 ActiveOverlay::None,
876 true
877 ),
878 InputAction::MapTargetNudge { dx: 1, dy: 0 }
879 );
880 assert_eq!(
881 state.apply_ui_key(
882 key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
883 ActiveOverlay::None,
884 true
885 ),
886 InputAction::MapTargetNudge { dx: 1, dy: 0 }
887 );
888 assert_eq!(state.current(), MovementInput::Stop);
889 }
890
891 #[test]
892 fn release_stops_movement() {
893 let mut state = MovementState::default();
894 state.apply_ui_key(
895 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
896 ActiveOverlay::None,
897 false,
898 );
899 assert_eq!(state.current(), MovementInput::Forward);
900 assert_eq!(
901 state.apply_ui_key(
902 key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
903 ActiveOverlay::None,
904 false,
905 ),
906 InputAction::StopMovement
907 );
908 assert_eq!(state.current(), MovementInput::Stop);
909 }
910
911 #[test]
912 fn sync_physical_holds_keeps_and_clears_wasd() {
913 let mut state = MovementState::default();
914 state.sync_physical_holds(true, false, false, false, false, false, false);
915 assert_eq!(state.current(), MovementInput::Forward);
916 state.expire_idle(Duration::from_millis(0));
918 state.sync_physical_holds(true, false, false, false, false, false, false);
919 assert_eq!(state.current(), MovementInput::Forward);
920 state.sync_physical_holds(true, false, false, true, false, false, true);
921 assert_eq!(state.current(), MovementInput::ForwardRight);
922 assert!(state.sprinting());
923 state.sync_physical_holds(false, false, false, false, false, false, false);
924 assert_eq!(state.current(), MovementInput::Stop);
925 }
926
927 #[test]
928 fn idle_timeout_stops_movement() {
929 let mut state = MovementState::default();
930 state.apply_ui_key(
931 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
932 ActiveOverlay::None,
933 false,
934 );
935 assert_eq!(state.current(), MovementInput::Forward);
936 state.expire_idle(Duration::from_millis(0));
937 assert_eq!(state.current(), MovementInput::Stop);
938 }
939
940 #[test]
941 fn shift_uppercase_w_moves_forward() {
942 let mut state = MovementState::default();
943 let mut key = key(UiKeyCode::Char('W'), UiKeyEventKind::Press);
944 key.modifiers = UiKeyModifiers {
945 shift: true,
946 control: false,
947 alt: false,
948 };
949 state.apply_ui_key(key, ActiveOverlay::None, false);
950 assert_eq!(state.current(), MovementInput::Forward);
951 assert!(state.sprinting());
952 }
953
954 #[test]
955 fn diagonal_w_and_d() {
956 let mut state = MovementState::default();
957 state.apply_ui_key(
958 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
959 ActiveOverlay::None,
960 false,
961 );
962 state.apply_ui_key(
963 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
964 ActiveOverlay::None,
965 false,
966 );
967 assert_eq!(state.current(), MovementInput::ForwardRight);
968 let (f, s) = state.current().components();
969 assert!(f > 0.0 && s > 0.0);
970 }
971
972 #[test]
973 fn single_d_after_expired_chord_is_right_only() {
974 let mut state = MovementState::default();
976 state.apply_ui_key(
977 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
978 ActiveOverlay::None,
979 false,
980 );
981 state.apply_ui_key(
982 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
983 ActiveOverlay::None,
984 false,
985 );
986 assert_eq!(state.current(), MovementInput::ForwardRight);
987 state.expire_idle(Duration::from_millis(0));
988 assert_eq!(state.current(), MovementInput::Stop);
989 state.apply_ui_key(
990 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
991 ActiveOverlay::None,
992 false,
993 );
994 assert_eq!(state.current(), MovementInput::Right);
995 }
996
997 #[test]
998 fn release_of_one_key_keeps_other_axis() {
999 let mut state = MovementState::default();
1000 state.apply_ui_key(
1001 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1002 ActiveOverlay::None,
1003 false,
1004 );
1005 state.apply_ui_key(
1006 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1007 ActiveOverlay::None,
1008 false,
1009 );
1010 state.chord_formed_at = Some(Instant::now() - Duration::from_millis(500));
1012 assert_eq!(
1013 state.apply_ui_key(
1014 key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1015 ActiveOverlay::None,
1016 false,
1017 ),
1018 InputAction::None
1019 );
1020 assert_eq!(state.current(), MovementInput::Right);
1021 }
1022
1023 #[test]
1024 fn chord_grace_keeps_diagonal_despite_spurious_release() {
1025 let mut state = MovementState::default();
1026 state.apply_ui_key(
1027 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1028 ActiveOverlay::None,
1029 false,
1030 );
1031 state.apply_ui_key(
1032 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1033 ActiveOverlay::None,
1034 false,
1035 );
1036 assert_eq!(
1038 state.apply_ui_key(
1039 key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1040 ActiveOverlay::None,
1041 false,
1042 ),
1043 InputAction::None
1044 );
1045 assert_eq!(state.current(), MovementInput::ForwardRight);
1046 state.apply_ui_key(
1047 key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
1048 ActiveOverlay::None,
1049 false,
1050 );
1051 assert_eq!(state.current(), MovementInput::ForwardRight);
1052 }
1053
1054 #[test]
1055 fn space_hard_stops() {
1056 let mut state = MovementState::default();
1057 state.apply_ui_key(
1058 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1059 ActiveOverlay::None,
1060 false,
1061 );
1062 state.apply_ui_key(
1063 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1064 ActiveOverlay::None,
1065 false,
1066 );
1067 assert_eq!(
1068 state.apply_ui_key(
1069 key(UiKeyCode::Char(' '), UiKeyEventKind::Press),
1070 ActiveOverlay::None,
1071 false
1072 ),
1073 InputAction::StopMovement
1074 );
1075 assert_eq!(state.current(), MovementInput::Stop);
1076 }
1077
1078 #[test]
1079 fn last_direction_remembered_after_release() {
1080 let mut state = MovementState::default();
1081 state.apply_ui_key(
1082 key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1083 ActiveOverlay::None,
1084 false,
1085 );
1086 let _ = state.apply_ui_key(
1087 key(UiKeyCode::Char('s'), UiKeyEventKind::Release),
1088 ActiveOverlay::None,
1089 false,
1090 );
1091 assert_eq!(state.current(), MovementInput::Stop);
1092 let (f, _) = state.last_move_axes();
1093 assert!(f < 0.0);
1094 }
1095
1096 #[test]
1097 fn repeat_keeps_diagonal_pair() {
1098 let mut state = MovementState::default();
1099 state.apply_ui_key(
1100 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1101 ActiveOverlay::None,
1102 false,
1103 );
1104 state.apply_ui_key(
1105 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1106 ActiveOverlay::None,
1107 false,
1108 );
1109 state.apply_ui_key(
1110 key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
1111 ActiveOverlay::None,
1112 false,
1113 );
1114 assert_eq!(state.current(), MovementInput::ForwardRight);
1115 }
1116
1117 #[test]
1118 fn shift_held_sprints_without_modifier_on_repeat() {
1119 let mut state = MovementState::default();
1120 let shift_press = UiKeyEvent {
1121 code: UiKeyCode::ShiftLeft,
1122 modifiers: UiKeyModifiers::default(),
1123 kind: UiKeyEventKind::Press,
1124 };
1125 state.apply_ui_key(shift_press, ActiveOverlay::None, false);
1126 state.apply_ui_key(
1127 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1128 ActiveOverlay::None,
1129 false,
1130 );
1131 assert!(state.sprinting());
1132 state.apply_ui_key(
1133 key(UiKeyCode::Char('w'), UiKeyEventKind::Repeat),
1134 ActiveOverlay::None,
1135 false,
1136 );
1137 assert!(state.sprinting());
1138 }
1139
1140 #[test]
1141 fn diagonal_normalized() {
1142 let (f, s) = MovementInput::ForwardRight.components();
1143 let len = (f * f + s * s).sqrt();
1144 assert!((len - 1.0).abs() < 0.001);
1145 }
1146
1147 #[test]
1148 fn overlay_esc_closes() {
1149 let mut state = MovementState::default();
1150 assert_eq!(
1151 state.apply_ui_key(
1152 key(UiKeyCode::Esc, UiKeyEventKind::Press),
1153 ActiveOverlay::Loadout,
1154 false
1155 ),
1156 InputAction::CloseOverlay
1157 );
1158 }
1159
1160 #[test]
1161 fn stats_overlay_tab_cycles_sheet_not_combat_target() {
1162 let mut state = MovementState::default();
1163 assert_eq!(
1164 state.apply_ui_key(
1165 key(UiKeyCode::Tab, UiKeyEventKind::Press),
1166 ActiveOverlay::Stats,
1167 false
1168 ),
1169 InputAction::CycleCharacterSheetTab
1170 );
1171 assert_eq!(
1172 state.apply_ui_key(
1173 key(UiKeyCode::BackTab, UiKeyEventKind::Press),
1174 ActiveOverlay::Stats,
1175 false
1176 ),
1177 InputAction::CycleCharacterSheetTab
1178 );
1179 assert_eq!(
1180 state.apply_ui_key(
1181 key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1182 ActiveOverlay::Stats,
1183 false
1184 ),
1185 InputAction::LedgerPeriodDigit('1')
1186 );
1187 assert_eq!(
1188 state.apply_ui_key(
1189 key(UiKeyCode::Char('i'), UiKeyEventKind::Press),
1190 ActiveOverlay::Stats,
1191 false
1192 ),
1193 InputAction::ToggleStats
1194 );
1195 assert_ne!(
1197 state.apply_ui_key(
1198 key(UiKeyCode::Tab, UiKeyEventKind::Press),
1199 ActiveOverlay::Stats,
1200 false
1201 ),
1202 InputAction::CycleCombatTarget { reverse: false }
1203 );
1204 }
1205
1206 #[test]
1207 fn overlay_allows_toggle_keys() {
1208 let mut state = MovementState::default();
1209 assert_eq!(
1210 state.apply_ui_key(
1211 key(UiKeyCode::Char('l'), UiKeyEventKind::Press),
1212 ActiveOverlay::Loadout,
1213 false
1214 ),
1215 InputAction::ToggleLoadout
1216 );
1217 assert_eq!(
1218 state.apply_ui_key(
1219 key(UiKeyCode::Char('o'), UiKeyEventKind::Press),
1220 ActiveOverlay::RotationEditor(RotationEditorMode::List),
1221 false
1222 ),
1223 InputAction::ToggleRotationEditor
1224 );
1225 }
1226
1227 #[test]
1228 fn overlay_honors_key_release() {
1229 let mut state = MovementState::default();
1230 state.apply_ui_key(
1231 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1232 ActiveOverlay::None,
1233 false,
1234 );
1235 assert_eq!(state.current(), MovementInput::Forward);
1236 state.apply_ui_key(
1237 key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1238 ActiveOverlay::Loadout,
1239 false,
1240 );
1241 assert_eq!(state.current(), MovementInput::Stop);
1242 }
1243
1244 #[test]
1245 fn overlay_assign_keys() {
1246 let mut state = MovementState::default();
1247 assert_eq!(
1248 state.apply_ui_key(
1249 key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1250 ActiveOverlay::Loadout,
1251 false
1252 ),
1253 InputAction::LoadoutAssignT1
1254 );
1255 assert_eq!(
1256 state.apply_ui_key(
1257 key(UiKeyCode::Char('2'), UiKeyEventKind::Press),
1258 ActiveOverlay::Loadout,
1259 false
1260 ),
1261 InputAction::LoadoutAssignT2
1262 );
1263 assert_eq!(
1264 state.apply_ui_key(
1265 key(UiKeyCode::Enter, UiKeyEventKind::Press),
1266 ActiveOverlay::Loadout,
1267 false
1268 ),
1269 InputAction::LoadoutBindHotbar
1270 );
1271 assert_eq!(
1272 state.apply_ui_key(
1273 key(UiKeyCode::Delete, UiKeyEventKind::Press),
1274 ActiveOverlay::Loadout,
1275 false
1276 ),
1277 InputAction::LoadoutClearHotbar
1278 );
1279 assert_eq!(
1280 state.apply_ui_key(
1281 key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
1282 ActiveOverlay::Loadout,
1283 false
1284 ),
1285 InputAction::LoadoutHotbarNext
1286 );
1287 }
1288
1289 #[test]
1290 fn rotation_editor_slash_moves_ability_down() {
1291 let mut state = MovementState::default();
1292 assert_eq!(
1293 state.apply_ui_key(
1294 key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
1295 ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1296 false
1297 ),
1298 InputAction::RotationEditorMoveAbilityDown
1299 );
1300 }
1301
1302 #[test]
1303 fn rotation_editor_bracket_keys_reorder() {
1304 let mut state = MovementState::default();
1305 assert_eq!(
1306 state.apply_ui_key(
1307 key(UiKeyCode::Char('['), UiKeyEventKind::Press),
1308 ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1309 false
1310 ),
1311 InputAction::RotationEditorMoveAbilityUp
1312 );
1313 assert_eq!(
1314 state.apply_ui_key(
1315 key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
1316 ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1317 false
1318 ),
1319 InputAction::RotationEditorMoveAbilityDown
1320 );
1321 }
1322
1323 #[test]
1324 fn rotation_editor_s_saves() {
1325 let mut state = MovementState::default();
1326 assert_eq!(
1327 state.apply_ui_key(
1328 key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1329 ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1330 false
1331 ),
1332 InputAction::RotationEditorSave
1333 );
1334 }
1335}