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