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