use std::collections::HashMap;
use std::time::{Duration, Instant};
use crate::input::{UiKeyCode, UiKeyEvent, UiKeyEventKind};
use flatland_client_lib::{ClientKeyBindings, RotationEditorMode};
use crate::keymap::{combat_action_for_key, CombatKeyAction};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ActiveOverlay {
#[default]
None,
Loadout,
RotationEditor(RotationEditorMode),
Stats,
}
pub const MOVEMENT_IDLE_TIMEOUT: Duration = Duration::from_millis(750);
const CHORD_RELEASE_GRACE: Duration = Duration::from_millis(220);
const SPRINT_SHIFT_REFRESH: Duration = Duration::from_millis(700);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum DirectionKey {
Up,
Down,
Left,
Right,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum VerticalKey {
Up,
Down,
}
fn direction_from_key(code: UiKeyCode) -> Option<DirectionKey> {
match code {
UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
'w' => Some(DirectionKey::Up),
's' => Some(DirectionKey::Down),
'a' => Some(DirectionKey::Left),
'd' => Some(DirectionKey::Right),
_ => None,
},
UiKeyCode::Up => Some(DirectionKey::Up),
UiKeyCode::Down => Some(DirectionKey::Down),
UiKeyCode::Left => Some(DirectionKey::Left),
UiKeyCode::Right => Some(DirectionKey::Right),
_ => None,
}
}
fn direction_axes(dir: DirectionKey) -> (f32, f32) {
match dir {
DirectionKey::Up => (1.0, 0.0),
DirectionKey::Down => (-1.0, 0.0),
DirectionKey::Left => (0.0, -1.0),
DirectionKey::Right => (0.0, 1.0),
}
}
fn is_shift_key(code: UiKeyCode) -> bool {
matches!(code, UiKeyCode::ShiftLeft | UiKeyCode::ShiftRight)
}
#[derive(Debug, Default)]
pub struct MapTargetState {
pub active: bool,
pub cursor_x: f32,
pub cursor_y: f32,
}
impl MapTargetState {
pub fn activate_at(&mut self, x: f32, y: f32) {
self.active = true;
self.cursor_x = x;
self.cursor_y = y;
}
pub fn deactivate(&mut self) {
self.active = false;
}
pub fn nudge(&mut self, dx: i32, dy: i32, max_x: f32, max_y: f32) {
self.cursor_x = (self.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
self.cursor_y = (self.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
}
}
#[derive(Debug)]
pub struct MovementState {
held_dirs: HashMap<DirectionKey, Instant>,
vertical_held: HashMap<VerticalKey, Instant>,
shift_held: bool,
sprint_until: Instant,
sprint_toggle: bool,
last_forward: f32,
last_strafe: f32,
chord_formed_at: Option<Instant>,
pub keys: ClientKeyBindings,
}
impl Default for MovementState {
fn default() -> Self {
Self {
held_dirs: HashMap::new(),
vertical_held: HashMap::new(),
shift_held: false,
sprint_until: Instant::now(),
sprint_toggle: false,
last_forward: 0.0,
last_strafe: 0.0,
chord_formed_at: None,
keys: ClientKeyBindings::default(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MovementInput {
Stop,
Forward,
Back,
Left,
Right,
ForwardLeft,
ForwardRight,
BackLeft,
BackRight,
}
impl MovementInput {
pub fn components(self) -> (f32, f32) {
let (mut forward, mut strafe): (f32, f32) = match self {
Self::Stop => (0.0, 0.0),
Self::Forward => (1.0, 0.0),
Self::Back => (-1.0, 0.0),
Self::Left => (0.0, -1.0),
Self::Right => (0.0, 1.0),
Self::ForwardLeft => (1.0, -1.0),
Self::ForwardRight => (1.0, 1.0),
Self::BackLeft => (-1.0, -1.0),
Self::BackRight => (-1.0, 1.0),
};
let len = (forward * forward + strafe * strafe).sqrt();
if len > 1.0 {
forward /= len;
strafe /= len;
}
(forward, strafe)
}
pub fn label(self) -> &'static str {
match self {
Self::Stop => "stop",
Self::Forward => "up",
Self::Back => "down",
Self::Left => "left",
Self::Right => "right",
Self::ForwardLeft => "up-left",
Self::ForwardRight => "up-right",
Self::BackLeft => "down-left",
Self::BackRight => "down-right",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum InputAction {
Quit,
Harvest,
Pickup,
Craft,
Interact,
TestDamage,
CycleCombatTarget {
reverse: bool,
},
CycleCombatTargetT2 {
reverse: bool,
},
AdvanceRotationT1,
AdvanceRotationT2,
ToggleAutoT1,
ToggleAutoT2,
ToggleLoadout,
ToggleRotationEditor,
LoadoutAssignT1,
LoadoutAssignT2,
LoadoutMenuUp,
LoadoutMenuDown,
LoadoutHotbarPrev,
LoadoutHotbarNext,
LoadoutBindHotbar,
LoadoutClearHotbar,
LoadoutToggleFocus,
RotationEditorListUp,
RotationEditorListDown,
RotationEditorEdit,
RotationEditorNew,
RotationEditorDelete,
RotationEditorBack,
RotationEditorAddAbility,
RotationEditorRemoveAbility,
RotationEditorMoveAbilityUp,
RotationEditorMoveAbilityDown,
RotationEditorAbilityUp,
RotationEditorAbilityDown,
RotationEditorPickerUp,
RotationEditorPickerDown,
RotationEditorPickAbility,
RotationEditorRename,
RotationEditorConfirmLabel,
RotationEditorLabelBackspace,
RotationEditorLabelChar(char),
RotationEditorSave,
CloseOverlay,
ClearCombatTarget,
ToggleStats,
ToggleEquip,
CycleCharacterSheetTab,
LedgerPeriodDigit(char),
ToggleInventory,
ToggleKeychain,
ToggleQuestMenu,
QuestMenuUp,
QuestMenuDown,
QuestWithdraw,
ToggleWorkersMenu,
ToggleHelp,
CycleHudView,
ToggleHudLog,
StartChat {
whisper: bool,
},
SubmitChat,
CancelChat,
Dodge,
Lunge,
DirectionalJump {
forward: f32,
strafe: f32,
},
ToggleBlock,
ToggleSprintMode,
ToggleMapTarget,
ConfirmMapTarget,
CancelMapTarget,
MapTargetNudge {
dx: i32,
dy: i32,
},
CancelAutoNav,
StopMovement,
UseWorld,
CastHotbar {
slot: u8,
},
ClearCombatTargetT2,
None,
}
fn rotation_editor_action(key: UiKeyEvent, mode: RotationEditorMode) -> Option<InputAction> {
let shift = key.modifiers.shift;
Some(match mode {
RotationEditorMode::List => match key.code {
UiKeyCode::Esc => InputAction::CloseOverlay,
UiKeyCode::Up => InputAction::RotationEditorListUp,
UiKeyCode::Down => InputAction::RotationEditorListDown,
UiKeyCode::Enter => InputAction::RotationEditorEdit,
UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
'n' => InputAction::RotationEditorNew,
'd' => InputAction::RotationEditorDelete,
_ => return None,
},
_ => return None,
},
RotationEditorMode::EditSequence => match key.code {
UiKeyCode::Up if shift => InputAction::RotationEditorMoveAbilityUp,
UiKeyCode::Down if shift => InputAction::RotationEditorMoveAbilityDown,
UiKeyCode::Esc => InputAction::RotationEditorBack,
UiKeyCode::Char('[') | UiKeyCode::Char(';') => InputAction::RotationEditorMoveAbilityUp,
UiKeyCode::Char(']') | UiKeyCode::Char('/') | UiKeyCode::Char('\\') => {
InputAction::RotationEditorMoveAbilityDown
}
UiKeyCode::Up => InputAction::RotationEditorAbilityUp,
UiKeyCode::Down => InputAction::RotationEditorAbilityDown,
UiKeyCode::Delete => InputAction::RotationEditorRemoveAbility,
UiKeyCode::Enter => InputAction::RotationEditorSave,
UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
'a' => InputAction::RotationEditorAddAbility,
'x' => InputAction::RotationEditorRemoveAbility,
'r' => InputAction::RotationEditorRename,
's' => InputAction::RotationEditorSave,
_ => return None,
},
_ => return None,
},
RotationEditorMode::PickAbility => match key.code {
UiKeyCode::Esc => InputAction::RotationEditorBack,
UiKeyCode::Up => InputAction::RotationEditorPickerUp,
UiKeyCode::Down => InputAction::RotationEditorPickerDown,
UiKeyCode::Enter => InputAction::RotationEditorPickAbility,
_ => return None,
},
RotationEditorMode::EditLabel => match key.code {
UiKeyCode::Esc => InputAction::RotationEditorBack,
UiKeyCode::Enter => InputAction::RotationEditorConfirmLabel,
UiKeyCode::Backspace => InputAction::RotationEditorLabelBackspace,
UiKeyCode::Char(c) if !key.modifiers.control => InputAction::RotationEditorLabelChar(c),
_ => return None,
},
})
}
impl MovementState {
pub fn with_keys(keys: ClientKeyBindings) -> Self {
Self {
keys,
..Default::default()
}
}
pub fn idle_timeout(&self) -> Duration {
MOVEMENT_IDLE_TIMEOUT
}
fn touch_dir(&mut self, dir: DirectionKey) {
let now = Instant::now();
let idle = self.idle_timeout();
let joining_chord = !self.held_dirs.contains_key(&dir)
&& self.held_dirs.values().any(|at| at.elapsed() < idle);
if joining_chord {
self.chord_formed_at = Some(now);
}
self.held_dirs.insert(dir, now);
for (other, at) in self.held_dirs.iter_mut() {
if *other != dir && at.elapsed() < idle {
*at = now;
}
}
}
fn release_dir(&mut self, dir: DirectionKey) {
self.held_dirs.remove(&dir);
}
fn refresh_sprint(&mut self, key: &UiKeyEvent) {
let shift = key.modifiers.shift
|| matches!(
key.code,
UiKeyCode::Char(c)
if c.is_ascii_uppercase() && direction_from_key(key.code).is_some()
);
if shift {
self.sprint_until = Instant::now() + SPRINT_SHIFT_REFRESH;
}
}
fn dir_active(&self, dir: DirectionKey) -> bool {
let idle = self.idle_timeout();
self.held_dirs
.get(&dir)
.is_some_and(|at| at.elapsed() < idle)
}
fn vertical_active(&self, key: VerticalKey) -> bool {
let idle = self.idle_timeout();
self.vertical_held
.get(&key)
.is_some_and(|at| at.elapsed() < idle)
}
pub fn reset(&mut self) {
self.held_dirs.clear();
self.vertical_held.clear();
self.shift_held = false;
self.sprint_until = Instant::now();
self.chord_formed_at = None;
}
pub fn sprint_mode(&self) -> bool {
self.sprint_toggle
}
pub fn toggle_sprint_mode(&mut self) {
self.sprint_toggle = !self.sprint_toggle;
}
pub fn vertical_axis(&self) -> f32 {
let up = self.vertical_active(VerticalKey::Up);
let down = self.vertical_active(VerticalKey::Down);
match (up, down) {
(true, false) => 1.0,
(false, true) => -1.0,
_ => 0.0,
}
}
pub fn sprinting(&self) -> bool {
self.sprint_toggle || self.shift_held || Instant::now() < self.sprint_until
}
pub fn last_move_axes(&self) -> (f32, f32) {
(self.last_forward, self.last_strafe)
}
fn remember_movement(&mut self) {
let (forward, strafe) = self.current().components();
if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
self.last_forward = forward;
self.last_strafe = strafe;
}
}
pub fn current(&self) -> MovementInput {
let up = self.dir_active(DirectionKey::Up);
let down = self.dir_active(DirectionKey::Down);
let left = self.dir_active(DirectionKey::Left);
let right = self.dir_active(DirectionKey::Right);
let forward = match (up, down) {
(true, false) => 1,
(false, true) => -1,
_ => 0,
};
let strafe = match (left, right) {
(true, false) => -1,
(false, true) => 1,
_ => 0,
};
match (forward, strafe) {
(1, 0) => MovementInput::Forward,
(-1, 0) => MovementInput::Back,
(0, -1) => MovementInput::Left,
(0, 1) => MovementInput::Right,
(1, -1) => MovementInput::ForwardLeft,
(1, 1) => MovementInput::ForwardRight,
(-1, -1) => MovementInput::BackLeft,
(-1, 1) => MovementInput::BackRight,
_ => MovementInput::Stop,
}
}
pub fn apply_ui_key(
&mut self,
key: UiKeyEvent,
overlay: ActiveOverlay,
map_target_active: bool,
) -> InputAction {
if key.kind == UiKeyEventKind::Press
&& key.modifiers.control
&& matches!(key.code, UiKeyCode::Char('q') | UiKeyCode::Char('c'))
{
return InputAction::Quit;
}
if overlay != ActiveOverlay::None {
if key.kind == UiKeyEventKind::Release {
if let Some(dir) = direction_from_key(key.code) {
self.release_dir(dir);
self.remember_movement();
}
match key.code {
UiKeyCode::Char('u') => {
self.vertical_held.remove(&VerticalKey::Up);
}
UiKeyCode::Char('j') => {
self.vertical_held.remove(&VerticalKey::Down);
}
_ => {}
}
return InputAction::None;
}
if key.kind == UiKeyEventKind::Press {
let action = match overlay {
ActiveOverlay::Loadout => match key.code {
UiKeyCode::Esc => Some(InputAction::CloseOverlay),
UiKeyCode::Up => Some(InputAction::LoadoutMenuUp),
UiKeyCode::Down => Some(InputAction::LoadoutMenuDown),
UiKeyCode::Left | UiKeyCode::Char('[') => {
Some(InputAction::LoadoutHotbarPrev)
}
UiKeyCode::Right | UiKeyCode::Char(']') => {
Some(InputAction::LoadoutHotbarNext)
}
UiKeyCode::Tab | UiKeyCode::BackTab => {
Some(InputAction::LoadoutToggleFocus)
}
UiKeyCode::Enter => Some(InputAction::LoadoutBindHotbar),
UiKeyCode::Delete | UiKeyCode::Backspace => {
Some(InputAction::LoadoutClearHotbar)
}
UiKeyCode::Char('1') => Some(InputAction::LoadoutAssignT1),
UiKeyCode::Char('2') => Some(InputAction::LoadoutAssignT2),
_ => None,
},
ActiveOverlay::RotationEditor(mode) => rotation_editor_action(key, mode),
ActiveOverlay::Stats => match key.code {
UiKeyCode::Esc => Some(InputAction::ToggleStats),
UiKeyCode::Tab | UiKeyCode::BackTab => {
Some(InputAction::CycleCharacterSheetTab)
}
UiKeyCode::Char('i') | UiKeyCode::Char('I') => {
Some(InputAction::ToggleStats)
}
UiKeyCode::Char(c) if matches!(c, '1' | '2' | '3' | '4') => {
Some(InputAction::LedgerPeriodDigit(c))
}
_ => None,
},
ActiveOverlay::None => None,
};
if let Some(action) = action {
return action;
}
if let Some(action) = combat_action_for_key(&key, &self.keys) {
return match action {
CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
_ => InputAction::None,
};
}
}
return InputAction::None;
}
if map_target_active {
if matches!(key.kind, UiKeyEventKind::Press | UiKeyEventKind::Repeat) {
return match key.code {
UiKeyCode::Esc => InputAction::CancelMapTarget,
UiKeyCode::Enter => InputAction::ConfirmMapTarget,
UiKeyCode::Char('m') => InputAction::CancelMapTarget,
UiKeyCode::Char(' ') => InputAction::StopMovement,
UiKeyCode::Char('w') | UiKeyCode::Up => {
InputAction::MapTargetNudge { dx: 0, dy: 1 }
}
UiKeyCode::Char('s') | UiKeyCode::Down => {
InputAction::MapTargetNudge { dx: 0, dy: -1 }
}
UiKeyCode::Char('a') | UiKeyCode::Left => {
InputAction::MapTargetNudge { dx: -1, dy: 0 }
}
UiKeyCode::Char('d') | UiKeyCode::Right => {
InputAction::MapTargetNudge { dx: 1, dy: 0 }
}
UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
'w' => InputAction::MapTargetNudge { dx: 0, dy: 1 },
's' => InputAction::MapTargetNudge { dx: 0, dy: -1 },
'a' => InputAction::MapTargetNudge { dx: -1, dy: 0 },
'd' => InputAction::MapTargetNudge { dx: 1, dy: 0 },
_ => InputAction::None,
},
_ => InputAction::None,
};
}
return InputAction::None;
}
if key.kind == UiKeyEventKind::Press {
if let Some(action) = combat_action_for_key(&key, &self.keys) {
return match action {
CombatKeyAction::CycleTargetT1 { reverse } => {
InputAction::CycleCombatTarget { reverse }
}
CombatKeyAction::CycleTargetT2 { reverse } => {
InputAction::CycleCombatTargetT2 { reverse }
}
CombatKeyAction::ToggleAutoT1 => InputAction::ToggleAutoT1,
CombatKeyAction::ToggleAutoT2 => InputAction::ToggleAutoT2,
CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
CombatKeyAction::Dodge => InputAction::Dodge,
CombatKeyAction::Lunge => InputAction::Lunge,
CombatKeyAction::ToggleBlock => InputAction::ToggleBlock,
CombatKeyAction::ClearTargetT1 => InputAction::ClearCombatTarget,
CombatKeyAction::ClearTargetT2 => InputAction::ClearCombatTargetT2,
CombatKeyAction::Hotbar(slot) => InputAction::CastHotbar { slot },
};
}
match key.code {
UiKeyCode::Tab => return InputAction::CycleCharacterSheetTab,
UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
'f' => return InputAction::UseWorld,
'n' if !key.modifiers.control => return InputAction::Craft,
',' => return InputAction::ToggleKeychain,
'i' => return InputAction::ToggleStats,
'p' if !key.modifiers.control => return InputAction::ToggleEquip,
'1' | '2' | '3' | '4' => {
return InputAction::LedgerPeriodDigit(c.to_ascii_lowercase())
}
'b' if !key.modifiers.control => return InputAction::ToggleInventory,
'v' if !key.modifiers.control => return InputAction::ToggleQuestMenu,
'h' if !key.modifiers.control => return InputAction::ToggleWorkersMenu,
'?' | '/' => return InputAction::ToggleHelp,
'.' => return InputAction::CycleHudView,
'\'' => return InputAction::ToggleHudLog,
'-' => return InputAction::TestDamage,
't' => return InputAction::StartChat { whisper: false },
'g' => return InputAction::StartChat { whisper: true },
'x' => return InputAction::ToggleSprintMode,
'm' => return InputAction::ToggleMapTarget,
' ' => {
self.reset();
return InputAction::StopMovement;
}
_ => {}
},
UiKeyCode::Enter => return InputAction::SubmitChat,
_ => {}
}
}
if is_shift_key(key.code) {
match key.kind {
UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
self.shift_held = true;
self.sprint_until = Instant::now() + Duration::from_secs(30);
}
UiKeyEventKind::Release => {
self.shift_held = false;
if !self.sprint_toggle {
self.sprint_until = Instant::now();
}
}
}
return InputAction::None;
}
let vertical = match key.code {
UiKeyCode::Char('u') => Some(VerticalKey::Up),
UiKeyCode::Char('j') => Some(VerticalKey::Down),
_ => None,
};
if let Some(v) = vertical {
match key.kind {
UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
self.vertical_held.insert(v, Instant::now());
}
UiKeyEventKind::Release => {
self.vertical_held.remove(&v);
}
}
return InputAction::None;
}
let Some(dir) = direction_from_key(key.code) else {
return InputAction::None;
};
if key.modifiers.alt && key.kind == UiKeyEventKind::Press {
let (forward, strafe) = direction_axes(dir);
return InputAction::DirectionalJump { forward, strafe };
}
match key.kind {
UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
self.touch_dir(dir);
self.refresh_sprint(&key);
self.remember_movement();
InputAction::None
}
UiKeyEventKind::Release => {
let was_held = self.held_dirs.contains_key(&dir);
let in_chord_grace = self
.chord_formed_at
.is_some_and(|t| t.elapsed() < CHORD_RELEASE_GRACE);
if was_held && !in_chord_grace {
self.release_dir(dir);
if self.held_dirs.len() < 2 {
self.chord_formed_at = None;
}
self.remember_movement();
if self.current() == MovementInput::Stop {
return InputAction::StopMovement;
}
}
InputAction::None
}
}
}
pub fn expire_idle(&mut self, idle: Duration) {
self.held_dirs.retain(|_, at| at.elapsed() < idle);
self.vertical_held.retain(|_, at| at.elapsed() < idle);
if self.held_dirs.len() < 2 {
self.chord_formed_at = None;
}
}
pub fn sync_physical_holds(
&mut self,
forward: bool,
back: bool,
left: bool,
right: bool,
vertical_up: bool,
vertical_down: bool,
shift: bool,
) {
let now = Instant::now();
for (dir, held) in [
(DirectionKey::Up, forward),
(DirectionKey::Down, back),
(DirectionKey::Left, left),
(DirectionKey::Right, right),
] {
if held {
self.held_dirs.insert(dir, now);
} else {
self.held_dirs.remove(&dir);
}
}
for (key, held) in [
(VerticalKey::Up, vertical_up),
(VerticalKey::Down, vertical_down),
] {
if held {
self.vertical_held.insert(key, now);
} else {
self.vertical_held.remove(&key);
}
}
self.shift_held = shift;
if shift {
self.sprint_until = now + SPRINT_SHIFT_REFRESH;
}
if self.held_dirs.len() < 2 {
self.chord_formed_at = None;
}
self.remember_movement();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::{UiKeyCode, UiKeyEventKind, UiKeyModifiers};
fn key(code: UiKeyCode, kind: UiKeyEventKind) -> UiKeyEvent {
UiKeyEvent {
code,
modifiers: UiKeyModifiers::default(),
kind,
}
}
#[test]
fn combat_keys_on_left_hand() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('c'), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::Dodge
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('q'), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::ToggleBlock
);
assert_eq!(
state.apply_ui_key(
UiKeyEvent {
code: UiKeyCode::Char(' '),
modifiers: UiKeyModifiers {
shift: true,
control: false,
alt: false
},
kind: UiKeyEventKind::Press
},
ActiveOverlay::None,
false
),
InputAction::Lunge
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('e'), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::None
);
assert_eq!(state.current(), MovementInput::Stop);
}
#[test]
fn world_and_utility_row_bindings() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('f'), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::UseWorld
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('n'), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::Craft
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char(','), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::ToggleKeychain
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::ToggleHelp
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::CastHotbar { slot: 1 }
);
}
#[test]
fn map_target_repeat_nudges_cursor() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
ActiveOverlay::None,
true
),
InputAction::MapTargetNudge { dx: 1, dy: 0 }
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
ActiveOverlay::None,
true
),
InputAction::MapTargetNudge { dx: 1, dy: 0 }
);
assert_eq!(state.current(), MovementInput::Stop);
}
#[test]
fn release_stops_movement() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::Forward);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
ActiveOverlay::None,
false,
),
InputAction::StopMovement
);
assert_eq!(state.current(), MovementInput::Stop);
}
#[test]
fn sync_physical_holds_keeps_and_clears_wasd() {
let mut state = MovementState::default();
state.sync_physical_holds(true, false, false, false, false, false, false);
assert_eq!(state.current(), MovementInput::Forward);
state.expire_idle(Duration::from_millis(0));
state.sync_physical_holds(true, false, false, false, false, false, false);
assert_eq!(state.current(), MovementInput::Forward);
state.sync_physical_holds(true, false, false, true, false, false, true);
assert_eq!(state.current(), MovementInput::ForwardRight);
assert!(state.sprinting());
state.sync_physical_holds(false, false, false, false, false, false, false);
assert_eq!(state.current(), MovementInput::Stop);
}
#[test]
fn idle_timeout_stops_movement() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::Forward);
state.expire_idle(Duration::from_millis(0));
assert_eq!(state.current(), MovementInput::Stop);
}
#[test]
fn shift_uppercase_w_moves_forward() {
let mut state = MovementState::default();
let mut key = key(UiKeyCode::Char('W'), UiKeyEventKind::Press);
key.modifiers = UiKeyModifiers {
shift: true,
control: false,
alt: false,
};
state.apply_ui_key(key, ActiveOverlay::None, false);
assert_eq!(state.current(), MovementInput::Forward);
assert!(state.sprinting());
}
#[test]
fn diagonal_w_and_d() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::ForwardRight);
let (f, s) = state.current().components();
assert!(f > 0.0 && s > 0.0);
}
#[test]
fn single_d_after_expired_chord_is_right_only() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::ForwardRight);
state.expire_idle(Duration::from_millis(0));
assert_eq!(state.current(), MovementInput::Stop);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::Right);
}
#[test]
fn release_of_one_key_keeps_other_axis() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
state.chord_formed_at = Some(Instant::now() - Duration::from_millis(500));
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
ActiveOverlay::None,
false,
),
InputAction::None
);
assert_eq!(state.current(), MovementInput::Right);
}
#[test]
fn chord_grace_keeps_diagonal_despite_spurious_release() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
ActiveOverlay::None,
false,
),
InputAction::None
);
assert_eq!(state.current(), MovementInput::ForwardRight);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::ForwardRight);
}
#[test]
fn space_hard_stops() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char(' '), UiKeyEventKind::Press),
ActiveOverlay::None,
false
),
InputAction::StopMovement
);
assert_eq!(state.current(), MovementInput::Stop);
}
#[test]
fn last_direction_remembered_after_release() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
let _ = state.apply_ui_key(
key(UiKeyCode::Char('s'), UiKeyEventKind::Release),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::Stop);
let (f, _) = state.last_move_axes();
assert!(f < 0.0);
}
#[test]
fn repeat_keeps_diagonal_pair() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
state.apply_ui_key(
key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::ForwardRight);
}
#[test]
fn shift_held_sprints_without_modifier_on_repeat() {
let mut state = MovementState::default();
let shift_press = UiKeyEvent {
code: UiKeyCode::ShiftLeft,
modifiers: UiKeyModifiers::default(),
kind: UiKeyEventKind::Press,
};
state.apply_ui_key(shift_press, ActiveOverlay::None, false);
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert!(state.sprinting());
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Repeat),
ActiveOverlay::None,
false,
);
assert!(state.sprinting());
}
#[test]
fn diagonal_normalized() {
let (f, s) = MovementInput::ForwardRight.components();
let len = (f * f + s * s).sqrt();
assert!((len - 1.0).abs() < 0.001);
}
#[test]
fn overlay_esc_closes() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Esc, UiKeyEventKind::Press),
ActiveOverlay::Loadout,
false
),
InputAction::CloseOverlay
);
}
#[test]
fn stats_overlay_tab_cycles_sheet_not_combat_target() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Tab, UiKeyEventKind::Press),
ActiveOverlay::Stats,
false
),
InputAction::CycleCharacterSheetTab
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::BackTab, UiKeyEventKind::Press),
ActiveOverlay::Stats,
false
),
InputAction::CycleCharacterSheetTab
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
ActiveOverlay::Stats,
false
),
InputAction::LedgerPeriodDigit('1')
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('i'), UiKeyEventKind::Press),
ActiveOverlay::Stats,
false
),
InputAction::ToggleStats
);
assert_ne!(
state.apply_ui_key(
key(UiKeyCode::Tab, UiKeyEventKind::Press),
ActiveOverlay::Stats,
false
),
InputAction::CycleCombatTarget { reverse: false }
);
}
#[test]
fn overlay_allows_toggle_keys() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('l'), UiKeyEventKind::Press),
ActiveOverlay::Loadout,
false
),
InputAction::ToggleLoadout
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('o'), UiKeyEventKind::Press),
ActiveOverlay::RotationEditor(RotationEditorMode::List),
false
),
InputAction::ToggleRotationEditor
);
}
#[test]
fn overlay_honors_key_release() {
let mut state = MovementState::default();
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
ActiveOverlay::None,
false,
);
assert_eq!(state.current(), MovementInput::Forward);
state.apply_ui_key(
key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
ActiveOverlay::Loadout,
false,
);
assert_eq!(state.current(), MovementInput::Stop);
}
#[test]
fn overlay_assign_keys() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
ActiveOverlay::Loadout,
false
),
InputAction::LoadoutAssignT1
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('2'), UiKeyEventKind::Press),
ActiveOverlay::Loadout,
false
),
InputAction::LoadoutAssignT2
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Enter, UiKeyEventKind::Press),
ActiveOverlay::Loadout,
false
),
InputAction::LoadoutBindHotbar
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Delete, UiKeyEventKind::Press),
ActiveOverlay::Loadout,
false
),
InputAction::LoadoutClearHotbar
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
ActiveOverlay::Loadout,
false
),
InputAction::LoadoutHotbarNext
);
}
#[test]
fn rotation_editor_slash_moves_ability_down() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
false
),
InputAction::RotationEditorMoveAbilityDown
);
}
#[test]
fn rotation_editor_bracket_keys_reorder() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('['), UiKeyEventKind::Press),
ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
false
),
InputAction::RotationEditorMoveAbilityUp
);
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
false
),
InputAction::RotationEditorMoveAbilityDown
);
}
#[test]
fn rotation_editor_s_saves() {
let mut state = MovementState::default();
assert_eq!(
state.apply_ui_key(
key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
false
),
InputAction::RotationEditorSave
);
}
}