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