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