From bc3b08b78168a0bcd44460ecdcadf246125baca5 Mon Sep 17 00:00:00 2001 From: Tomato <67799071+100-TomatoJuice@users.noreply.github.com> Date: Sun, 7 Jan 2024 15:28:20 -0700 Subject: [PATCH] Accessible Deadzones (#438) * Accessible deadzones * Turn off "missing docs" * Add tests * Update some docs * Use `unwrap_or_default()`instead of `None` * cargo fmt * cargo clippy * Use `f32::EPSILON` * Add const for zero deadzone * Only scale input with unclamped size * Zero deadzone dual mouse wheel * Update docs * Update RELEASES.md * Explain input scaling * Turn back on `missing_docs` * Cargo fmt * Update RELEASES.md Co-authored-by: Alice Cecile * Update RELEASES.md Co-authored-by: Alice Cecile --------- Co-authored-by: Alice Cecile --- RELEASES.md | 8 +++ src/axislike.rs | 145 +++++++++++++++++++++--------------------- src/input_streams.rs | 42 +++++++----- tests/gamepad_axis.rs | 127 ++++++++++++------------------------ tests/mouse_motion.rs | 2 +- tests/mouse_wheel.rs | 2 +- 6 files changed, 147 insertions(+), 179 deletions(-) diff --git a/RELEASES.md b/RELEASES.md index 36c3eb02..6570272b 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -8,6 +8,14 @@ - Added `InputMap::Clear`. - Fixed [a bug](https://github.com/Leafwing-Studios/leafwing-input-manager/issues/430) related to incorrect axis data in `Chord` when not all buttons are pressed. +### Enhancements + +- Improved deadzone handling for both `DualAxis` and `SingleAxis` deadzones + - All deadzones now scale the input so that it is continuous. + - `DeadZoneShape::Cross` handles each axis seperately, making a per-axis "snapping" effect. + - An input that falls on the exact boundary of a deadzone is now considered inside it. + + ## Version 0.11.2 - fixed [a bug](https://github.com/Leafwing-Studios/leafwing-input-manager/issues/285) with mouse motion and mouse wheel events being improperly counted diff --git a/src/axislike.rs b/src/axislike.rs index 458d1851..1cef8c0c 100644 --- a/src/axislike.rs +++ b/src/axislike.rs @@ -227,6 +227,14 @@ impl DualAxis { radius_y: Self::DEFAULT_DEADZONE, }; + /// A deadzone with a size of 0.0 used by constructor methods. + /// + /// This cannot be changed, but the struct can be easily manually constructed. + pub const ZERO_DEADZONE_SHAPE: DeadZoneShape = DeadZoneShape::Ellipse { + radius_x: 0.0, + radius_y: 0.0, + }; + /// Creates a [`DualAxis`] with both `positive_low` and `negative_low` in both axes set to `threshold` with a `deadzone_shape`. #[must_use] pub fn symmetric( @@ -284,7 +292,7 @@ impl DualAxis { DualAxis { x: SingleAxis::mouse_wheel_x(), y: SingleAxis::mouse_wheel_y(), - deadzone: Self::DEFAULT_DEADZONE_SHAPE, + deadzone: Self::ZERO_DEADZONE_SHAPE, } } @@ -293,7 +301,7 @@ impl DualAxis { DualAxis { x: SingleAxis::mouse_motion_x(), y: SingleAxis::mouse_motion_y(), - deadzone: Self::DEFAULT_DEADZONE_SHAPE, + deadzone: Self::ZERO_DEADZONE_SHAPE, } } @@ -732,32 +740,28 @@ impl From for Vec2 { /// The shape of the deadzone for a [`DualAxis`] input. /// -/// Input values that are on the boundary of the shape are counted as outside. -/// If a volume of a shape is 0, then all input values are read. +/// Input values that are on the boundary of the shape are counted as inside. +/// If a size of a shape is 0.0, then all input values are read, except for 0.0. +/// +/// All inputs are scaled to be continuous. +/// So with a ellipse deadzone of a radius of 0.1, the input range `0.1..=1.0` will be scaled to `0.0..=1.0`. /// /// Deadzone values should be in the range `0.0..=1.0`. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Reflect)] pub enum DeadZoneShape { /// Deadzone with the shape of a cross. /// - /// The cross is represented by two rectangles. When using [`DeadZoneShape::Cross`], - /// make sure rect_1 and rect_2 do not have the same values, otherwise the shape will be a rectangle + /// The cross is represented by horizonal and vertical rectangles. + /// Each axis is handled seperately which creates a per-axis "snapping" effect. Cross { - /// The width of the first rectangle. - rect_1_width: f32, - /// The height of the first rectangle. - rect_1_height: f32, - /// The width of the second rectangle. - rect_2_width: f32, - /// The height of the second rectangle. - rect_2_height: f32, - }, - /// Deadzone with the shape of a rectangle. - Rect { - /// The width of the rectangle. - width: f32, - /// The height of the rectangle. - height: f32, + /// The width of the horizonal axis. + /// + /// Affects the snapping of the y-axis. + horizontal_width: f32, + /// The width of the vertical axis. + /// + /// Affects the snapping of the x-axis. + vertical_width: f32, }, /// Deadzone with the shape of an ellipse. Ellipse { @@ -773,19 +777,11 @@ impl std::hash::Hash for DeadZoneShape { fn hash(&self, state: &mut H) { match self { DeadZoneShape::Cross { - rect_1_width, - rect_1_height, - rect_2_width, - rect_2_height, + horizontal_width, + vertical_width, } => { - FloatOrd(*rect_1_width).hash(state); - FloatOrd(*rect_1_height).hash(state); - FloatOrd(*rect_2_width).hash(state); - FloatOrd(*rect_2_height).hash(state); - } - DeadZoneShape::Rect { width, height } => { - FloatOrd(*width).hash(state); - FloatOrd(*height).hash(state); + FloatOrd(*horizontal_width).hash(state); + FloatOrd(*vertical_width).hash(state); } DeadZoneShape::Ellipse { radius_x, radius_y } => { FloatOrd(*radius_x).hash(state); @@ -796,54 +792,59 @@ impl std::hash::Hash for DeadZoneShape { } impl DeadZoneShape { - /// Returns whether the (x, y) input is outside the deadzone. - pub fn input_outside_deadzone(&self, x: f32, y: f32) -> bool { + /// Computes the input value based on the deadzone. + pub fn deadzone_input_value(&self, x: f32, y: f32) -> Option { + let value = Vec2::new(x, y); + match self { DeadZoneShape::Cross { - rect_1_width, - rect_1_height, - rect_2_width, - rect_2_height, - } => self.outside_cross( - x, - y, - *rect_1_width, - *rect_1_height, - *rect_2_width, - *rect_2_height, - ), - DeadZoneShape::Rect { width, height } => self.outside_rectangle(x, y, *width, *height), + horizontal_width, + vertical_width, + } => self.cross_deadzone_value(value, *horizontal_width, *vertical_width), DeadZoneShape::Ellipse { radius_x, radius_y } => { - self.outside_ellipse(x, y, *radius_x, *radius_y) + self.ellipse_deadzone_value(value, *radius_x, *radius_y) } } } - /// Returns whether the (x, y) input is outside a cross. - fn outside_cross( + /// Computes the input value based on the cross deadzone. + fn cross_deadzone_value( &self, - x: f32, - y: f32, - rect_1_width: f32, - rect_1_height: f32, - rect_2_width: f32, - rect_2_height: f32, - ) -> bool { - self.outside_rectangle(x, y, rect_1_width, rect_1_height) - && self.outside_rectangle(x, y, rect_2_width, rect_2_height) - } - - /// Returns whether the (x, y) input is outside a rectangle. - fn outside_rectangle(&self, x: f32, y: f32, width: f32, height: f32) -> bool { - x >= width || x <= -width || y >= height || y <= -height - } - - /// Returns whether the (x, y) input is outside an ellipse. - fn outside_ellipse(&self, x: f32, y: f32, radius_x: f32, radius_y: f32) -> bool { - if radius_x == 0.0 || radius_y == 0.0 { - return true; + value: Vec2, + horizontal_width: f32, + vertical_width: f32, + ) -> Option { + let new_x = f32::from(value.x.abs() > vertical_width) * value.x; + let new_y = f32::from(value.y.abs() > horizontal_width) * value.y; + let new_value = Vec2::new(new_x, new_y); + + if new_value == Vec2::ZERO { + None + } else { + let scaled_value = + Self::scale_value(new_value, Vec2::new(vertical_width, horizontal_width)); + Some(DualAxisData::from_xy(scaled_value)) } + } + + /// Computes the input value based on the ellipse deadzone. + fn ellipse_deadzone_value( + &self, + value: Vec2, + radius_x: f32, + radius_y: f32, + ) -> Option { + let clamped_radius_x = radius_x.max(f32::EPSILON); + let clamped_radius_y = radius_y.max(f32::EPSILON); + if (value.x / clamped_radius_x).powi(2) + (value.y / clamped_radius_y).powi(2) < 1.0 { + return None; + } + + let scaled_value = Self::scale_value(value, Vec2::new(radius_x, radius_y)); + Some(DualAxisData::from_xy(scaled_value)) + } - ((x / radius_x).powi(2) + (y / radius_y).powi(2)) >= 1.0 + fn scale_value(value: Vec2, deadzone_size: Vec2) -> Vec2 { + value.signum() * (value.abs() - deadzone_size).max(Vec2::ZERO) / (1.0 - deadzone_size) } } diff --git a/src/input_streams.rs b/src/input_streams.rs index dfbf85bc..2503074a 100644 --- a/src/input_streams.rs +++ b/src/input_streams.rs @@ -133,10 +133,12 @@ impl<'a> InputStreams<'a> { let y_value = self.input_value(&UserInput::Single(InputKind::SingleAxis(axis.y)), false); - axis.deadzone.input_outside_deadzone(x_value, y_value) + axis.deadzone + .deadzone_input_value(x_value, y_value) + .is_some() } InputKind::SingleAxis(axis) => { - let value = self.input_value(&UserInput::Single(button), true); + let value = self.input_value(&UserInput::Single(button), false); value < axis.negative_low || value > axis.positive_low } @@ -266,14 +268,24 @@ impl<'a> InputStreams<'a> { // Helper that takes the value returned by an axis and returns 0.0 if it is not within the // triggering range. - let value_in_axis_range = |axis: &SingleAxis, value: f32| -> f32 { - if value >= axis.negative_low && value <= axis.positive_low && include_deadzone { - 0.0 - } else if axis.inverted { - -value * axis.sensitivity - } else { - value * axis.sensitivity + let value_in_axis_range = |axis: &SingleAxis, mut value: f32| -> f32 { + if include_deadzone { + if value >= axis.negative_low && value <= axis.positive_low { + return 0.0; + } + + let width = if value.is_sign_positive() { + axis.positive_low.abs() + } else { + axis.negative_low.abs() + }; + value = value.signum() * (value.abs() - width).max(0.0) / (1.0 - width); + } + if axis.inverted { + value *= -1.0; } + + value * axis.sensitivity }; match input { @@ -430,13 +442,13 @@ impl<'a> InputStreams<'a> { // Return result of the first dual axis in the chord. if let InputKind::DualAxis(dual_axis) = input_kind { - return Some(self.extract_dual_axis_data(dual_axis)); + return Some(self.extract_dual_axis_data(dual_axis).unwrap_or_default()); } } None } UserInput::Single(InputKind::DualAxis(dual_axis)) => { - Some(self.extract_dual_axis_data(dual_axis)) + Some(self.extract_dual_axis_data(dual_axis).unwrap_or_default()) } UserInput::VirtualDPad(VirtualDPad { up, @@ -454,7 +466,7 @@ impl<'a> InputStreams<'a> { } } - fn extract_dual_axis_data(&self, dual_axis: &DualAxis) -> DualAxisData { + fn extract_dual_axis_data(&self, dual_axis: &DualAxis) -> Option { let x = self.input_value( &UserInput::Single(InputKind::SingleAxis(dual_axis.x)), false, @@ -464,11 +476,7 @@ impl<'a> InputStreams<'a> { false, ); - if dual_axis.deadzone.input_outside_deadzone(x, y) { - DualAxisData::new(x, y) - } else { - DualAxisData::new(0.0, 0.0) - } + dual_axis.deadzone.deadzone_input_value(x, y) } } diff --git a/tests/gamepad_axis.rs b/tests/gamepad_axis.rs index 3a294977..d3e425d2 100644 --- a/tests/gamepad_axis.rs +++ b/tests/gamepad_axis.rs @@ -219,6 +219,21 @@ fn game_pad_single_axis() { app.update(); let action_state = app.world.resource::>(); assert!(!action_state.pressed(AxislikeTestAction::Y)); + + // Scaled value + let input = SingleAxis { + axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), + value: Some(0.2), + positive_low: 0.1, + negative_low: 0.1, + inverted: false, + sensitivity: 1.0, + }; + app.send_input(input); + app.update(); + let action_state = app.world.resource::>(); + assert!(action_state.pressed(AxislikeTestAction::X)); + assert!(action_state.value(AxislikeTestAction::X) == 0.11111112); } #[test] @@ -302,20 +317,18 @@ fn game_pad_dual_axis_cross() { let mut app = test_app(); app.insert_resource(InputMap::new([( DualAxis::left_stick().with_deadzone(DeadZoneShape::Cross { - rect_1_width: 0.1, - rect_1_height: 0.05, - rect_2_width: 0.05, - rect_2_height: 0.1, + horizontal_width: 0.1, + vertical_width: 0.1, }), AxislikeTestAction::XY, )])); - // Test that an input inside the cross deadzone is filtered out + // Test that an input inside the cross deadzone is filtered out. app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, 0.04, - 0.04, + 0.1, )); app.update(); @@ -328,59 +341,29 @@ fn game_pad_dual_axis_cross() { DualAxisData::new(0.0, 0.0) ); - // Test that an input outside the cross deadzone is not filtered out + // Test that an input outside the cross deadzone is not filtered out. app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, - 0.1, - 0.05, + 1.0, + 0.2, )); app.update(); let action_state = app.world.resource::>(); assert!(action_state.pressed(AxislikeTestAction::XY)); - assert_eq!(action_state.value(AxislikeTestAction::XY), 0.111803405); + assert_eq!(action_state.value(AxislikeTestAction::XY), 1.0061539); assert_eq!( action_state.axis_pair(AxislikeTestAction::XY).unwrap(), - DualAxisData::new(0.1, 0.05) + DualAxisData::new(1.0, 0.11111112) ); -} -#[test] -fn game_pad_dual_axis_rect() { - let mut app = test_app(); - app.insert_resource(InputMap::new([( - DualAxis::left_stick().with_deadzone(DeadZoneShape::Rect { - width: 0.1, - height: 0.1, - }), - AxislikeTestAction::XY, - )])); - - // Test that an input inside the rect deadzone is filtered out, assuming values of 0.1 - app.send_input(DualAxis::from_value( - GamepadAxisType::LeftStickX, - GamepadAxisType::LeftStickY, - 0.05, - 0.05, - )); - - app.update(); - - let action_state = app.world.resource::>(); - assert!(action_state.released(AxislikeTestAction::XY)); - assert_eq!(action_state.value(AxislikeTestAction::XY), 0.0); - assert_eq!( - action_state.axis_pair(AxislikeTestAction::XY).unwrap(), - DualAxisData::new(0.0, 0.0) - ); - - // Test that an input outside the rect deadzone is not filtered out, assuming values of 0.1 + // Test that each axis of the cross deadzone is filtered independently. app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, - 0.1, + 0.8, 0.1, )); @@ -388,10 +371,10 @@ fn game_pad_dual_axis_rect() { let action_state = app.world.resource::>(); assert!(action_state.pressed(AxislikeTestAction::XY)); - assert_eq!(action_state.value(AxislikeTestAction::XY), 0.14142136); + assert_eq!(action_state.value(AxislikeTestAction::XY), 0.7777778); assert_eq!( action_state.axis_pair(AxislikeTestAction::XY).unwrap(), - DualAxisData::new(0.1, 0.1) + DualAxisData::new(0.7777778, 0.0) ); } @@ -428,7 +411,7 @@ fn game_pad_dual_axis_ellipse() { app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, - 0.1, + 0.2, 0.0, )); @@ -436,57 +419,25 @@ fn game_pad_dual_axis_ellipse() { let action_state = app.world.resource::>(); assert!(action_state.pressed(AxislikeTestAction::XY)); - assert_eq!(action_state.value(AxislikeTestAction::XY), 0.1); + assert_eq!(action_state.value(AxislikeTestAction::XY), 0.11111112); assert_eq!( action_state.axis_pair(AxislikeTestAction::XY).unwrap(), - DualAxisData::new(0.1, 0.0) + DualAxisData::new(0.11111112, 0.0) ); } #[test] -fn test_zero_volume_cross() { +fn test_zero_cross() { let mut app = test_app(); app.insert_resource(InputMap::new([( DualAxis::left_stick().with_deadzone(DeadZoneShape::Cross { - rect_1_width: 0.0, - rect_1_height: 0.0, - rect_2_width: 0.0, - rect_2_height: 0.0, - }), - AxislikeTestAction::XY, - )])); - - // Test any input, even (0, 0), will count as input - app.send_input(DualAxis::from_value( - GamepadAxisType::LeftStickX, - GamepadAxisType::LeftStickY, - 0.0, - 0.0, - )); - - app.update(); - - let action_state = app.world.resource::>(); - assert!(action_state.pressed(AxislikeTestAction::XY)); - assert_eq!(action_state.value(AxislikeTestAction::XY), 0.0); - assert_eq!( - action_state.axis_pair(AxislikeTestAction::XY).unwrap(), - DualAxisData::new(0.0, 0.0) - ); -} - -#[test] -fn test_zero_volume_rect() { - let mut app = test_app(); - app.insert_resource(InputMap::new([( - DualAxis::left_stick().with_deadzone(DeadZoneShape::Rect { - width: 0.0, - height: 0.0, + horizontal_width: 0.0, + vertical_width: 0.0, }), AxislikeTestAction::XY, )])); - // Test any input, even (0, 0), will count as input + // Test that an input of zero will be `None` even with no deadzone. app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, @@ -497,7 +448,7 @@ fn test_zero_volume_rect() { app.update(); let action_state = app.world.resource::>(); - assert!(action_state.pressed(AxislikeTestAction::XY)); + assert!(action_state.released(AxislikeTestAction::XY)); assert_eq!(action_state.value(AxislikeTestAction::XY), 0.0); assert_eq!( action_state.axis_pair(AxislikeTestAction::XY).unwrap(), @@ -506,7 +457,7 @@ fn test_zero_volume_rect() { } #[test] -fn test_zero_volume_ellipse() { +fn test_zero_ellipse() { let mut app = test_app(); app.insert_resource(InputMap::new([( DualAxis::left_stick().with_deadzone(DeadZoneShape::Ellipse { @@ -516,7 +467,7 @@ fn test_zero_volume_ellipse() { AxislikeTestAction::XY, )])); - // Test any input, even (0, 0), will count as input + // Test that an input of zero will be `None` even with no deadzone. app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, @@ -527,7 +478,7 @@ fn test_zero_volume_ellipse() { app.update(); let action_state = app.world.resource::>(); - assert!(action_state.pressed(AxislikeTestAction::XY)); + assert!(action_state.released(AxislikeTestAction::XY)); assert_eq!(action_state.value(AxislikeTestAction::XY), 0.0); assert_eq!( action_state.axis_pair(AxislikeTestAction::XY).unwrap(), diff --git a/tests/mouse_motion.rs b/tests/mouse_motion.rs index 3852b85e..a7c4e158 100644 --- a/tests/mouse_motion.rs +++ b/tests/mouse_motion.rs @@ -106,7 +106,7 @@ fn mouse_motion_dual_axis_mocking() { inverted: false, sensitivity: 1.0, }, - deadzone: DualAxis::DEFAULT_DEADZONE_SHAPE, + deadzone: DualAxis::ZERO_DEADZONE_SHAPE, }; app.send_input(input); let mut events = app.world.resource_mut::>(); diff --git a/tests/mouse_wheel.rs b/tests/mouse_wheel.rs index da31f554..b1074c9d 100644 --- a/tests/mouse_wheel.rs +++ b/tests/mouse_wheel.rs @@ -107,7 +107,7 @@ fn mouse_wheel_dual_axis_mocking() { inverted: false, sensitivity: 1.0, }, - deadzone: DualAxis::DEFAULT_DEADZONE_SHAPE, + deadzone: DualAxis::ZERO_DEADZONE_SHAPE, }; app.send_input(input); let mut events = app.world.resource_mut::>();