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