diff --git a/Cargo.toml b/Cargo.toml index afea960a..4cde8dfb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,11 @@ derive_more = { version = "0.99", default-features = false, features = [ ] } itertools = "0.12" serde = { version = "1.0", features = ["derive"] } +serde_flexitos = "0.2" +dyn-clone = "1.0" +dyn-eq = "0.1" +dyn-hash = "0.2" +once_cell = "1.19" [dev-dependencies] bevy = { version = "0.13", default-features = false, features = [ diff --git a/RELEASES.md b/RELEASES.md index a9758eb4..63e7463d 100644 --- a/RELEASES.md +++ b/RELEASES.md @@ -4,7 +4,39 @@ ### Breaking Changes -- Removed `Direction` type. Use `bevy::math::primitives::Direction2d`. +- removed `Direction` type in favor of `bevy::math::primitives::Direction2d`. +- added input processors for `SingleAxis`, `DualAxis`, `VirtualAxis`, and `VirtualDpad` to refine input values: + - added processor traits: + - `AxisProcessor`: Handles single-axis values. + - `DualAxisProcessor`: Handles dual-axis values. + - added built-in processors: + - Pipelines: Combine multiple processors into a pipeline. + - `AxisProcessingPipeline`: Chain processors for single-axis values. + - `DualAxisProcessingPipeline`: Chain processors for dual-axis values. + - Inversion: Reverses control (positive becomes negative, etc.) + - `AxisInverted`: Single-axis inversion. + - `DualAxisInverted`: Dual-axis inversion. + - Sensitivity: Adjusts control responsiveness (doubling, halving, etc.). + - `AxisSensitivity`: Single-axis scaling. + - `DualAxisSensitivity`: Dual-axis scaling. + - Value Bounds: Define the boundaries for constraining input values. + - `AxisBounds`: Restricts single-axis values to a range. + - `DualAxisBounds`: Restricts single-axis values to a range along each axis. + - `CircleBounds`: Limits dual-axis values to a maximum magnitude. + - Deadzones: Ignores near-zero values, treating them as zero. + - Unscaled versions: + - `AxisExclusion`: Excludes small single-axis values. + - `DualAxisExclusion`: Excludes small dual-axis values along each axis. + - `CircleExclusion`: Excludes dual-axis values below a specified magnitude threshold. + - Scaled versions: + - `AxisDeadZone`: Normalizes single-axis values based on `AxisExclusion` and `AxisBounds::default`. + - `DualAxisDeadZone`: Normalizes dual-axis values based on `DualAxisExclusion` and `DualAxisBounds::default`. + - `CircleDeadZone`: Normalizes dual-axis values based on `CircleExclusion` and `CircleBounds::default`. + - removed `DeadZoneShape`. + - removed functions for inverting, adjusting sensitivity, and creating deadzones from `SingleAxis` and `DualAxis`. + - added `with_processor`, `replace_processor`, and `no_processor` to manage processors for `SingleAxis`, `DualAxis`, `VirtualAxis`, and `VirtualDpad`. + - added App extensions: `register_axis_processor` and `register_dual_axis_processor` for registration of processors. + - added `serde_typetag` procedural macro attribute for processor type tagging. ### Bugs diff --git a/examples/action_state_resource.rs b/examples/action_state_resource.rs index e8711c36..3f4c5633 100644 --- a/examples/action_state_resource.rs +++ b/examples/action_state_resource.rs @@ -5,7 +5,7 @@ //! and include it as a resource in a bevy app. use bevy::prelude::*; -use leafwing_input_manager::{prelude::*, user_input::InputKind}; +use leafwing_input_manager::prelude::*; fn main() { App::new() diff --git a/examples/arpg_indirection.rs b/examples/arpg_indirection.rs index 3b789a68..8651275c 100644 --- a/examples/arpg_indirection.rs +++ b/examples/arpg_indirection.rs @@ -112,19 +112,19 @@ fn spawn_player(mut commands: Commands) { fn copy_action_state( mut query: Query<( - &ActionState, + &mut ActionState, &mut ActionState, &AbilitySlotMap, )>, ) { - for (slot_state, mut ability_state, ability_slot_map) in query.iter_mut() { + for (mut slot_state, mut ability_state, ability_slot_map) in query.iter_mut() { for slot in Slot::variants() { if let Some(matching_ability) = ability_slot_map.get(&slot) { // This copies the `ActionData` between the ActionStates, // including information about how long the buttons have been pressed or released ability_state.set_action_data( *matching_ability, - slot_state.action_data(&slot).unwrap().clone(), + slot_state.action_data_mut_or_default(&slot).clone(), ); } } diff --git a/examples/axis_inputs.rs b/examples/axis_inputs.rs index 6e778401..c175b0dd 100644 --- a/examples/axis_inputs.rs +++ b/examples/axis_inputs.rs @@ -33,9 +33,11 @@ fn spawn_player(mut commands: Commands) { .insert(Action::Throttle, GamepadButtonType::RightTrigger2) // And we'll use the right stick's x-axis as a rudder control .insert( + // Add an AxisDeadzone to process horizontal values of the right stick. // This will trigger if the axis is moved 10% or more in either direction. Action::Rudder, - SingleAxis::symmetric(GamepadAxisType::RightStickX, 0.1), + SingleAxis::new(GamepadAxisType::RightStickX) + .with_processor(AxisDeadZone::magnitude(0.1)), ) .build(); commands diff --git a/examples/clash_handling.rs b/examples/clash_handling.rs index 07f0e8c0..58b10649 100644 --- a/examples/clash_handling.rs +++ b/examples/clash_handling.rs @@ -4,7 +4,6 @@ //! See [`ClashStrategy`] for more details. use bevy::prelude::*; -use leafwing_input_manager::clashing_inputs::ClashStrategy; use leafwing_input_manager::prelude::*; fn main() { diff --git a/examples/consuming_actions.rs b/examples/consuming_actions.rs index eddc07f0..457751d2 100644 --- a/examples/consuming_actions.rs +++ b/examples/consuming_actions.rs @@ -1,6 +1,5 @@ //! Demonstrates how to "consume" actions, so they can only be responded to by a single system -use bevy::ecs::system::Resource; use bevy::prelude::*; use leafwing_input_manager::prelude::*; diff --git a/examples/default_controls.rs b/examples/default_controls.rs index 7cadecf2..e43e14e3 100644 --- a/examples/default_controls.rs +++ b/examples/default_controls.rs @@ -20,7 +20,7 @@ enum PlayerAction { } impl PlayerAction { - /// Define the default binding to the input + /// Define the default bindings to the input fn default_input_map() -> InputMap { let mut input_map = InputMap::default(); diff --git a/examples/input_processing.rs b/examples/input_processing.rs new file mode 100644 index 00000000..bbf1d0bc --- /dev/null +++ b/examples/input_processing.rs @@ -0,0 +1,70 @@ +use bevy::prelude::*; +use leafwing_input_manager::prelude::*; + +fn main() { + App::new() + .add_plugins(DefaultPlugins) + .add_plugins(InputManagerPlugin::::default()) + .add_systems(Startup, spawn_player) + .add_systems(Update, check_data) + .run(); +} + +#[derive(Actionlike, PartialEq, Eq, Clone, Copy, Hash, Debug, Reflect)] +enum Action { + Move, + LookAround, +} + +#[derive(Component)] +struct Player; + +fn spawn_player(mut commands: Commands) { + let mut input_map = InputMap::default(); + input_map + .insert( + Action::Move, + VirtualDPad::wasd() + // You can add a processor to handle axis-like user inputs by using the `with_processor`. + // + // This processor is a circular deadzone that normalizes input values + // by clamping their magnitude to a maximum of 1.0, + // excluding those with a magnitude less than 0.1, + // and scaling other values linearly in between. + .with_processor(CircleDeadZone::new(0.1)) + // Followed by appending Y-axis inversion for the next processing step. + .with_processor(DualAxisInverted::ONLY_Y), + ) + .insert( + Action::Move, + DualAxis::left_stick() + // You can replace the currently used processor with another processor. + .replace_processor(CircleDeadZone::default()) + // Or remove the processor directly, leaving no processor applied. + .no_processor(), + ) + .insert( + Action::LookAround, + // You can also add a pipeline to handle axis-like user inputs. + DualAxis::mouse_motion().with_processor( + DualAxisProcessingPipeline::default() + // The first processor is a circular deadzone. + .with(CircleDeadZone::new(0.1)) + // The next processor doubles inputs normalized by the deadzone. + .with(DualAxisSensitivity::all(2.0)), + ), + ); + commands + .spawn(InputManagerBundle::with_map(input_map)) + .insert(Player); +} + +fn check_data(query: Query<&ActionState, With>) { + let action_state = query.single(); + for action in action_state.get_pressed() { + println!( + "Pressed {action:?}! Its data: {:?}", + action_state.axis_pair(&action) + ); + } +} diff --git a/examples/mouse_position.rs b/examples/mouse_position.rs index f910f7f9..7d79f1f0 100644 --- a/examples/mouse_position.rs +++ b/examples/mouse_position.rs @@ -60,8 +60,7 @@ fn update_cursor_state_from_window( if let Some(val) = window.cursor_position() { action_state - .action_data_mut(&driver.action) - .unwrap() + .action_data_mut_or_default(&driver.action) .axis_pair = Some(DualAxisData::from_xy(val)); } } diff --git a/examples/send_actions_over_network.rs b/examples/send_actions_over_network.rs index f111313f..4763a00e 100644 --- a/examples/send_actions_over_network.rs +++ b/examples/send_actions_over_network.rs @@ -6,7 +6,7 @@ //! Note that [`ActionState`] can also be serialized and sent directly. //! This approach will be less bandwidth efficient, but involve less complexity and CPU work. -use bevy::ecs::event::{Events, ManualEventReader}; +use bevy::ecs::event::ManualEventReader; use bevy::input::InputPlugin; use bevy::prelude::*; use leafwing_input_manager::action_diff::ActionDiffEvent; @@ -23,10 +23,6 @@ enum FpsAction { Shoot, } -/// This identifier uniquely identifies entities across the network -#[derive(Component, Clone, PartialEq, Eq, Hash, Debug)] -struct StableId(u64); - /// Processes an [`Events`] stream of [`ActionDiff`] to update an [`ActionState`] /// /// In a real scenario, you would have to map the entities between the server and client world. diff --git a/examples/twin_stick_controller.rs b/examples/twin_stick_controller.rs index e55c5cc2..dc45d2fa 100644 --- a/examples/twin_stick_controller.rs +++ b/examples/twin_stick_controller.rs @@ -39,7 +39,7 @@ pub enum PlayerAction { } impl PlayerAction { - /// Define the default binding to the input + /// Define the default bindings to the input fn default_input_map() -> InputMap { let mut input_map = InputMap::default(); diff --git a/examples/virtual_dpad.rs b/examples/virtual_dpad.rs index a11c76ca..77a4b922 100644 --- a/examples/virtual_dpad.rs +++ b/examples/virtual_dpad.rs @@ -32,6 +32,7 @@ fn spawn_player(mut commands: Commands) { down: KeyCode::KeyS.into(), left: KeyCode::KeyA.into(), right: KeyCode::KeyD.into(), + processor: None, }, )]); commands diff --git a/macros/src/actionlike.rs b/macros/src/actionlike.rs index 6adcc79a..6bf73cb2 100644 --- a/macros/src/actionlike.rs +++ b/macros/src/actionlike.rs @@ -1,8 +1,7 @@ -use proc_macro2::Span; +use crate::utils; use proc_macro2::TokenStream; -use proc_macro_crate::{crate_name, FoundCrate}; use quote::quote; -use syn::{DeriveInput, Ident}; +use syn::DeriveInput; /// This approach and implementation is inspired by the `strum` crate, /// Copyright (c) 2019 Peter Glotfelty @@ -13,26 +12,7 @@ pub(crate) fn actionlike_inner(ast: &DeriveInput) -> TokenStream { let enum_name = &ast.ident; let (impl_generics, type_generics, where_clause) = &ast.generics.split_for_impl(); - let crate_path = if let Ok(found_crate) = crate_name("leafwing_input_manager") { - // The crate was found in the Cargo.toml - match found_crate { - FoundCrate::Itself => quote!(leafwing_input_manager), - FoundCrate::Name(name) => { - let ident = Ident::new(&name, Span::call_site()); - quote!(#ident) - } - } - } else { - // The crate was not found in the Cargo.toml, - // so we assume that we are in the owning_crate itself - // - // In order for this to play nicely with unit tests within the crate itself, - // `use crate as leafwing_input_manager` at the top of each test module - // - // Note that doc tests, integration tests and examples want the full standard import, - // as they are evaluated as if they were external - quote!(leafwing_input_manager) - }; + let crate_path = utils::crate_path(); quote! { impl #impl_generics #crate_path::Actionlike for #enum_name #type_generics #where_clause {} diff --git a/macros/src/lib.rs b/macros/src/lib.rs index 2a26b989..39dff713 100644 --- a/macros/src/lib.rs +++ b/macros/src/lib.rs @@ -5,9 +5,14 @@ //! Copyright (c) 2019 Peter Glotfelty under the MIT License extern crate proc_macro; -mod actionlike; + use proc_macro::TokenStream; -use syn::DeriveInput; +use syn::{DeriveInput, ItemImpl}; + +mod actionlike; +mod typetag; + +mod utils; #[proc_macro_derive(Actionlike)] pub fn actionlike(input: TokenStream) -> TokenStream { @@ -15,3 +20,10 @@ pub fn actionlike(input: TokenStream) -> TokenStream { crate::actionlike::actionlike_inner(&ast).into() } + +#[proc_macro_attribute] +pub fn serde_typetag(_: TokenStream, input: TokenStream) -> TokenStream { + let ast = syn::parse_macro_input!(input as ItemImpl); + + crate::typetag::expand_serde_typetag(&ast).into() +} diff --git a/macros/src/typetag.rs b/macros/src/typetag.rs new file mode 100644 index 00000000..3476c839 --- /dev/null +++ b/macros/src/typetag.rs @@ -0,0 +1,68 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Error, ItemImpl, Type, TypePath}; + +use crate::utils; + +/// This approach and implementation is inspired by the `typetag` crate, +/// Copyright (c) 2019 David Tolnay +/// available under either of `Apache License, Version 2.0` or `MIT` license +/// at +pub(crate) fn expand_serde_typetag(input: &ItemImpl) -> TokenStream { + let Some(trait_) = &input.trait_ else { + let impl_token = input.impl_token; + let ty = &input.self_ty; + let span = quote!(#impl_token, #ty); + let msg = "expected impl Trait for Type"; + return Error::new_spanned(span, msg).to_compile_error(); + }; + + let trait_path = &trait_.1; + + let self_ty = input.self_ty.clone(); + + let ident = match type_name(&self_ty) { + Some(name) => quote!(#name), + None => { + let impl_token = input.impl_token; + let ty = &input.self_ty; + let span = quote!(#impl_token, #ty); + let msg = "expected explicit name for Type"; + return Error::new_spanned(span, msg).to_compile_error(); + } + }; + + let crate_path = utils::crate_path(); + + quote! { + #input + + impl<'de> #crate_path::typetag::RegisterTypeTag<'de, dyn #trait_path> for #self_ty { + fn register_typetag( + registry: &mut #crate_path::typetag::MapRegistry, + ) { + #crate_path::typetag::Registry::register( + registry, + #ident, + |de| Ok(::std::boxed::Box::new( + ::bevy::reflect::erased_serde::deserialize::<#self_ty>(de)?, + )), + ) + } + } + } +} + +fn type_name(mut ty: &Type) -> Option { + loop { + match ty { + Type::Group(group) => { + ty = &group.elem; + } + Type::Path(TypePath { qself, path }) if qself.is_none() => { + return Some(path.segments.last().unwrap().ident.to_string()) + } + _ => return None, + } + } +} diff --git a/macros/src/utils.rs b/macros/src/utils.rs new file mode 100644 index 00000000..61963708 --- /dev/null +++ b/macros/src/utils.rs @@ -0,0 +1,26 @@ +use proc_macro2::{Ident, Span, TokenStream}; +use proc_macro_crate::{crate_name, FoundCrate}; +use quote::quote; + +pub fn crate_path() -> TokenStream { + if let Ok(found_crate) = crate_name("leafwing_input_manager") { + // The crate was found in the Cargo.toml + match found_crate { + FoundCrate::Itself => quote!(leafwing_input_manager), + FoundCrate::Name(name) => { + let ident = Ident::new(&name, Span::call_site()); + quote!(#ident) + } + } + } else { + // The crate was not found in the Cargo.toml, + // so we assume that we are in the owning_crate itself + // + // In order for this to play nicely with unit tests within the crate itself, + // `use crate as leafwing_input_manager` at the top of each test module + // + // Note that doc tests, integration tests and examples want the full standard import, + // as they are evaluated as if they were external + quote!(leafwing_input_manager) + } +} diff --git a/src/axislike.rs b/src/axislike.rs index 5fca15c3..83167fb6 100644 --- a/src/axislike.rs +++ b/src/axislike.rs @@ -1,6 +1,7 @@ //! Tools for working with directional axis-like user inputs (game sticks, D-Pads and emulated equivalents) use crate::buttonlike::{MouseMotionDirection, MouseWheelDirection}; +use crate::input_processing::*; use crate::orientation::Rotation; use crate::user_input::InputKind; use bevy::input::{ @@ -10,7 +11,6 @@ use bevy::input::{ use bevy::math::primitives::Direction2d; use bevy::math::Vec2; use bevy::reflect::Reflect; -use bevy::utils::FloatOrd; use serde::{Deserialize, Serialize}; /// A single directional axis with a configurable trigger zone. @@ -20,22 +20,14 @@ use serde::{Deserialize, Serialize}; /// # Warning /// /// `positive_low` must be greater than or equal to `negative_low` for this type to be validly constructed. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, Reflect)] +#[derive(Debug, Clone, Serialize, Deserialize, Reflect)] pub struct SingleAxis { /// The axis that is being checked. pub axis_type: AxisType, - /// Any axis value higher than this will trigger the input. - pub positive_low: f32, - /// Any axis value lower than this will trigger the input. - pub negative_low: f32, - /// Whether to invert output values from this axis. - pub inverted: bool, - /// How sensitive the axis is to input values. - /// - /// Since sensitivity is a multiplier, any value `>1.0` will increase sensitivity while any value `<1.0` will decrease sensitivity. - /// This value should always be strictly positive: a value of 0 will cause the axis to stop functioning, - /// while negative values will invert the direction. - pub sensitivity: f32, + + /// The processor used to handle input values. + pub processor: Option>, + /// The target value for this input, used for input mocking. /// /// WARNING: this field is ignored for the sake of [`Eq`] and [`Hash`](std::hash::Hash) @@ -43,116 +35,109 @@ pub struct SingleAxis { } impl SingleAxis { - /// Creates a [`SingleAxis`] with the given `positive_low` and `negative_low` thresholds. + /// Creates a [`SingleAxis`] with the specified axis type. #[must_use] - pub const fn with_threshold( - axis_type: AxisType, - negative_low: f32, - positive_low: f32, - ) -> SingleAxis { - SingleAxis { - axis_type, - positive_low, - negative_low, - inverted: false, - sensitivity: 1.0, + pub fn new(axis_type: impl Into) -> Self { + Self { + axis_type: axis_type.into(), + processor: None, value: None, } } - /// Creates a [`SingleAxis`] with both `positive_low` and `negative_low` set to `threshold`. - #[must_use] - pub fn symmetric(axis_type: impl Into, threshold: f32) -> SingleAxis { - Self::with_threshold(axis_type.into(), -threshold, threshold) - } - - /// Creates a [`SingleAxis`] with the specified `axis_type` and `value`. + /// Creates a [`SingleAxis`] with the specified axis type and `value`. /// - /// All thresholds are set to 0.0. /// Primarily useful for [input mocking](crate::input_mocking). #[must_use] - pub fn from_value(axis_type: impl Into, value: f32) -> SingleAxis { - SingleAxis { + pub fn from_value(axis_type: impl Into, value: f32) -> Self { + Self { axis_type: axis_type.into(), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, value: Some(value), } } /// Creates a [`SingleAxis`] corresponding to horizontal [`MouseWheel`](bevy::input::mouse::MouseWheel) movement #[must_use] - pub const fn mouse_wheel_x() -> SingleAxis { - let axis_type = AxisType::MouseWheel(MouseWheelAxisType::X); - SingleAxis::with_threshold(axis_type, 0.0, 0.0) + pub const fn mouse_wheel_x() -> Self { + Self { + axis_type: AxisType::MouseWheel(MouseWheelAxisType::X), + processor: None, + value: None, + } } /// Creates a [`SingleAxis`] corresponding to vertical [`MouseWheel`](bevy::input::mouse::MouseWheel) movement #[must_use] - pub const fn mouse_wheel_y() -> SingleAxis { - let axis_type = AxisType::MouseWheel(MouseWheelAxisType::Y); - SingleAxis::with_threshold(axis_type, 0.0, 0.0) + pub const fn mouse_wheel_y() -> Self { + Self { + axis_type: AxisType::MouseWheel(MouseWheelAxisType::Y), + processor: None, + value: None, + } } /// Creates a [`SingleAxis`] corresponding to horizontal [`MouseMotion`](bevy::input::mouse::MouseMotion) movement #[must_use] - pub const fn mouse_motion_x() -> SingleAxis { - let axis_type = AxisType::MouseMotion(MouseMotionAxisType::X); - SingleAxis::with_threshold(axis_type, 0.0, 0.0) + pub const fn mouse_motion_x() -> Self { + Self { + axis_type: AxisType::MouseMotion(MouseMotionAxisType::X), + processor: None, + value: None, + } } /// Creates a [`SingleAxis`] corresponding to vertical [`MouseMotion`](bevy::input::mouse::MouseMotion) movement #[must_use] - pub const fn mouse_motion_y() -> SingleAxis { - let axis_type = AxisType::MouseMotion(MouseMotionAxisType::Y); - SingleAxis::with_threshold(axis_type, 0.0, 0.0) - } - - /// Creates a [`SingleAxis`] with the `axis_type` and `negative_low` set to `threshold`. - /// - /// Positive values will not trigger the input. - pub fn negative_only(axis_type: impl Into, threshold: f32) -> SingleAxis { - SingleAxis::with_threshold(axis_type.into(), threshold, f32::MAX) + pub const fn mouse_motion_y() -> Self { + Self { + axis_type: AxisType::MouseMotion(MouseMotionAxisType::Y), + processor: None, + value: None, + } } - /// Creates a [`SingleAxis`] with the `axis_type` and `positive_low` set to `threshold`. - /// - /// Negative values will not trigger the input. - pub fn positive_only(axis_type: impl Into, threshold: f32) -> SingleAxis { - SingleAxis::with_threshold(axis_type.into(), f32::MIN, threshold) + /// Appends the given [`AxisProcessor`] into the current [`AxisProcessingPipeline`], + /// or creates a new pipeline if one doesn't exist. + #[inline] + pub fn with_processor(mut self, processor: impl AxisProcessor) -> Self { + self.processor = match self.processor { + None => Some(Box::new(processor)), + Some(current_processor) => Some(current_processor.with_processor(processor)), + }; + self } - /// Returns this [`SingleAxis`] with the deadzone set to the specified value - #[must_use] - pub fn with_deadzone(mut self, deadzone: f32) -> SingleAxis { - self.negative_low = -deadzone; - self.positive_low = deadzone; + /// Replaces the current [`AxisProcessor`] with the specified `processor`. + #[inline] + pub fn replace_processor(mut self, processor: impl AxisProcessor) -> Self { + self.processor = Some(Box::new(processor)); self } - /// Returns this [`SingleAxis`] with the sensitivity set to the specified value - #[must_use] - pub fn with_sensitivity(mut self, sensitivity: f32) -> SingleAxis { - self.sensitivity = sensitivity; + /// Remove the current used [`AxisProcessor`]. + #[inline] + pub fn no_processor(mut self) -> Self { + self.processor = None; self } - /// Returns this [`SingleAxis`] inverted. + /// Get the "value" of this axis. + /// If a processor is set, it will compute and return the processed value. + /// Otherwise, pass the `input_value` through unchanged. #[must_use] - pub fn inverted(mut self) -> Self { - self.inverted = !self.inverted; - self + #[inline] + pub fn input_value(&self, input_value: f32) -> f32 { + match &self.processor { + Some(processor) => processor.process(input_value), + _ => input_value, + } } } impl PartialEq for SingleAxis { fn eq(&self, other: &Self) -> bool { - self.axis_type == other.axis_type - && FloatOrd(self.positive_low) == FloatOrd(other.positive_low) - && FloatOrd(self.negative_low) == FloatOrd(other.negative_low) - && FloatOrd(self.sensitivity) == FloatOrd(other.sensitivity) + self.axis_type == other.axis_type && self.processor == other.processor } } @@ -161,9 +146,7 @@ impl Eq for SingleAxis {} impl std::hash::Hash for SingleAxis { fn hash(&self, state: &mut H) { self.axis_type.hash(state); - FloatOrd(self.positive_low).hash(state); - FloatOrd(self.negative_low).hash(state); - FloatOrd(self.sensitivity).hash(state); + self.processor.hash(state); } } @@ -177,55 +160,37 @@ impl std::hash::Hash for SingleAxis { /// # Warning /// /// `positive_low` must be greater than or equal to `negative_low` for both `x` and `y` for this type to be validly constructed. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash, Reflect)] +#[derive(Debug, Clone, Serialize, Deserialize, Reflect)] pub struct DualAxis { - /// The axis representing horizontal movement. - pub x: SingleAxis, - /// The axis representing vertical movement. - pub y: SingleAxis, - /// The shape of the deadzone - pub deadzone: DeadZoneShape, -} + /// The horizontal axis that is being checked. + pub x_axis_type: AxisType, -impl DualAxis { - /// The default size of the deadzone used by constructor methods. - /// - /// This cannot be changed, but the struct can be easily manually constructed. - pub const DEFAULT_DEADZONE: f32 = 0.1; + /// The vertical axis that is being checked. + pub y_axis_type: AxisType, - /// The default shape of the deadzone used by constructor methods. - /// - /// This cannot be changed, but the struct can be easily manually constructed. - pub const DEFAULT_DEADZONE_SHAPE: DeadZoneShape = DeadZoneShape::Ellipse { - radius_x: Self::DEFAULT_DEADZONE, - radius_y: Self::DEFAULT_DEADZONE, - }; + /// The processor used to handle input values. + pub processor: Option>, - /// A deadzone with a size of 0.0 used by constructor methods. + /// The target value for this input, used for input mocking. /// - /// 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, - }; + /// WARNING: this field is ignored for the sake of [`Eq`] and [`Hash`](std::hash::Hash) + pub value: Option, +} - /// Creates a [`DualAxis`] with both `positive_low` and `negative_low` in both axes set to `threshold` with a `deadzone_shape`. +impl DualAxis { + /// Creates a [`DualAxis`] with the specified axis types. #[must_use] - pub fn symmetric( - x_axis_type: impl Into, - y_axis_type: impl Into, - deadzone_shape: DeadZoneShape, - ) -> DualAxis { - DualAxis { - x: SingleAxis::symmetric(x_axis_type, 0.0), - y: SingleAxis::symmetric(y_axis_type, 0.0), - deadzone: deadzone_shape, + pub fn new(x_axis_type: impl Into, y_axis_type: impl Into) -> Self { + Self { + x_axis_type: x_axis_type.into(), + y_axis_type: y_axis_type.into(), + processor: None, + value: None, } } - /// Creates a [`SingleAxis`] with the specified `axis_type` and `value`. + /// Creates a [`SingleAxis`] with the specified axis types and `value`. /// - /// All thresholds are set to 0.0. /// Primarily useful for [input mocking](crate::input_mocking). #[must_use] pub fn from_value( @@ -233,91 +198,110 @@ impl DualAxis { y_axis_type: impl Into, x_value: f32, y_value: f32, - ) -> DualAxis { - DualAxis { - x: SingleAxis::from_value(x_axis_type, x_value), - y: SingleAxis::from_value(y_axis_type, y_value), - deadzone: Self::DEFAULT_DEADZONE_SHAPE, + ) -> Self { + Self { + x_axis_type: x_axis_type.into(), + y_axis_type: y_axis_type.into(), + processor: None, + value: Some(Vec2::new(x_value, y_value)), } } /// Creates a [`DualAxis`] for the left analogue stick of the gamepad. #[must_use] - pub const fn left_stick() -> DualAxis { - let axis_x = AxisType::Gamepad(GamepadAxisType::LeftStickX); - let axis_y = AxisType::Gamepad(GamepadAxisType::LeftStickY); - DualAxis { - x: SingleAxis::with_threshold(axis_x, 0.0, 0.0), - y: SingleAxis::with_threshold(axis_y, 0.0, 0.0), - deadzone: Self::DEFAULT_DEADZONE_SHAPE, + pub fn left_stick() -> Self { + Self { + x_axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), + y_axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), + processor: Some(Box::::default()), + value: None, } } /// Creates a [`DualAxis`] for the right analogue stick of the gamepad. #[must_use] - pub const fn right_stick() -> DualAxis { - let axis_x = AxisType::Gamepad(GamepadAxisType::RightStickX); - let axis_y = AxisType::Gamepad(GamepadAxisType::RightStickY); - DualAxis { - x: SingleAxis::with_threshold(axis_x, 0.0, 0.0), - y: SingleAxis::with_threshold(axis_y, 0.0, 0.0), - deadzone: Self::DEFAULT_DEADZONE_SHAPE, + pub fn right_stick() -> Self { + Self { + x_axis_type: AxisType::Gamepad(GamepadAxisType::RightStickX), + y_axis_type: AxisType::Gamepad(GamepadAxisType::RightStickY), + processor: Some(Box::::default()), + value: None, } } /// Creates a [`DualAxis`] corresponding to horizontal and vertical [`MouseWheel`](bevy::input::mouse::MouseWheel) movement - pub const fn mouse_wheel() -> DualAxis { - DualAxis { - x: SingleAxis::mouse_wheel_x(), - y: SingleAxis::mouse_wheel_y(), - deadzone: Self::ZERO_DEADZONE_SHAPE, + pub const fn mouse_wheel() -> Self { + Self { + x_axis_type: AxisType::MouseWheel(MouseWheelAxisType::X), + y_axis_type: AxisType::MouseWheel(MouseWheelAxisType::Y), + processor: None, + value: None, } } /// Creates a [`DualAxis`] corresponding to horizontal and vertical [`MouseMotion`](bevy::input::mouse::MouseMotion) movement - pub const fn mouse_motion() -> DualAxis { - DualAxis { - x: SingleAxis::mouse_motion_x(), - y: SingleAxis::mouse_motion_y(), - deadzone: Self::ZERO_DEADZONE_SHAPE, + pub const fn mouse_motion() -> Self { + Self { + x_axis_type: AxisType::MouseMotion(MouseMotionAxisType::X), + y_axis_type: AxisType::MouseMotion(MouseMotionAxisType::Y), + processor: None, + value: None, } } - /// Returns this [`DualAxis`] with the deadzone set to the specified values and shape - #[must_use] - pub fn with_deadzone(mut self, deadzone: DeadZoneShape) -> DualAxis { - self.deadzone = deadzone; + /// Appends the given [`DualAxisProcessor`] into the current [`DualAxisProcessingPipeline`], + /// or creates a new pipeline if one doesn't exist. + #[inline] + pub fn with_processor(mut self, processor: impl DualAxisProcessor) -> Self { + self.processor = match self.processor { + None => Some(Box::new(processor)), + Some(current_processor) => Some(current_processor.with_processor(processor)), + }; self } - /// Returns this [`DualAxis`] with the sensitivity set to the specified values - #[must_use] - pub fn with_sensitivity(mut self, x_sensitivity: f32, y_sensitivity: f32) -> DualAxis { - self.x.sensitivity = x_sensitivity; - self.y.sensitivity = y_sensitivity; + /// Replaces the current [`DualAxisProcessor`] with the specified `processor`. + #[inline] + pub fn replace_processor(mut self, processor: impl DualAxisProcessor) -> Self { + self.processor = Some(Box::new(processor)); self } - /// Returns this [`DualAxis`] with an inverted X-axis. - #[must_use] - pub fn inverted_x(mut self) -> DualAxis { - self.x = self.x.inverted(); + /// Remove the current used [`DualAxisProcessor`]. + #[inline] + pub fn no_processor(mut self) -> Self { + self.processor = None; self } - /// Returns this [`DualAxis`] with an inverted Y-axis. + /// Get the "value" of these axes. + /// If a processor is set, it will compute and return the processed value. + /// Otherwise, pass the `input_value` through unchanged. #[must_use] - pub fn inverted_y(mut self) -> DualAxis { - self.y = self.y.inverted(); - self + #[inline] + pub fn input_value(&self, input_value: Vec2) -> Vec2 { + match &self.processor { + Some(processor) => processor.process(input_value), + _ => input_value, + } + } +} + +impl PartialEq for DualAxis { + fn eq(&self, other: &Self) -> bool { + self.x_axis_type == other.x_axis_type + && self.y_axis_type == other.y_axis_type + && self.processor == other.processor } +} - /// Returns this [`DualAxis`] with both axes inverted. - #[must_use] - pub fn inverted(mut self) -> DualAxis { - self.x = self.x.inverted(); - self.y = self.y.inverted(); - self +impl Eq for DualAxis {} + +impl std::hash::Hash for DualAxis { + fn hash(&self, state: &mut H) { + self.x_axis_type.hash(state); + self.y_axis_type.hash(state); + self.processor.hash(state); } } @@ -338,16 +322,19 @@ pub struct VirtualDPad { pub left: InputKind, /// The input that represents the right direction in this virtual DPad pub right: InputKind, + /// The processor used to handle input values. + pub processor: Option>, } impl VirtualDPad { /// Generates a [`VirtualDPad`] corresponding to the arrow keyboard keycodes - pub const fn arrow_keys() -> VirtualDPad { + pub fn arrow_keys() -> VirtualDPad { VirtualDPad { up: InputKind::PhysicalKey(KeyCode::ArrowUp), down: InputKind::PhysicalKey(KeyCode::ArrowDown), left: InputKind::PhysicalKey(KeyCode::ArrowLeft), right: InputKind::PhysicalKey(KeyCode::ArrowRight), + processor: Some(Box::::default()), } } @@ -357,23 +344,25 @@ impl VirtualDPad { /// The _location_ of the keys is the same on all keyboard layouts. /// This ensures that the classic triangular shape is retained on all layouts, /// which enables comfortable movement controls. - pub const fn wasd() -> VirtualDPad { + pub fn wasd() -> VirtualDPad { VirtualDPad { up: InputKind::PhysicalKey(KeyCode::KeyW), down: InputKind::PhysicalKey(KeyCode::KeyS), left: InputKind::PhysicalKey(KeyCode::KeyA), right: InputKind::PhysicalKey(KeyCode::KeyD), + processor: Some(Box::::default()), } } #[allow(clippy::doc_markdown)] // False alarm because it thinks DPad is an unquoted item /// Generates a [`VirtualDPad`] corresponding to the DPad on a gamepad - pub const fn dpad() -> VirtualDPad { + pub fn dpad() -> VirtualDPad { VirtualDPad { up: InputKind::GamepadButton(GamepadButtonType::DPadUp), down: InputKind::GamepadButton(GamepadButtonType::DPadDown), left: InputKind::GamepadButton(GamepadButtonType::DPadLeft), right: InputKind::GamepadButton(GamepadButtonType::DPadRight), + processor: Some(Box::::default()), } } @@ -381,53 +370,74 @@ impl VirtualDPad { /// /// North corresponds to up, west corresponds to left, /// east corresponds to right, and south corresponds to down - pub const fn gamepad_face_buttons() -> VirtualDPad { + pub fn gamepad_face_buttons() -> VirtualDPad { VirtualDPad { up: InputKind::GamepadButton(GamepadButtonType::North), down: InputKind::GamepadButton(GamepadButtonType::South), left: InputKind::GamepadButton(GamepadButtonType::West), right: InputKind::GamepadButton(GamepadButtonType::East), + processor: Some(Box::::default()), } } /// Generates a [`VirtualDPad`] corresponding to discretized mousewheel movements - pub const fn mouse_wheel() -> VirtualDPad { + pub fn mouse_wheel() -> VirtualDPad { VirtualDPad { up: InputKind::MouseWheel(MouseWheelDirection::Up), down: InputKind::MouseWheel(MouseWheelDirection::Down), left: InputKind::MouseWheel(MouseWheelDirection::Left), right: InputKind::MouseWheel(MouseWheelDirection::Right), + processor: None, } } /// Generates a [`VirtualDPad`] corresponding to discretized mouse motions - pub const fn mouse_motion() -> VirtualDPad { + pub fn mouse_motion() -> VirtualDPad { VirtualDPad { up: InputKind::MouseMotion(MouseMotionDirection::Up), down: InputKind::MouseMotion(MouseMotionDirection::Down), left: InputKind::MouseMotion(MouseMotionDirection::Left), right: InputKind::MouseMotion(MouseMotionDirection::Right), + processor: None, } } - /// Returns this [`VirtualDPad`] but with `up` and `down` swapped. - pub fn inverted_y(mut self) -> Self { - std::mem::swap(&mut self.up, &mut self.down); + /// Appends the given [`DualAxisProcessor`] into the current [`DualAxisProcessingPipeline`], + /// or creates a new pipeline if one doesn't exist. + #[inline] + pub fn with_processor(mut self, processor: impl DualAxisProcessor) -> Self { + self.processor = match self.processor { + None => Some(Box::new(processor)), + Some(current_processor) => Some(current_processor.with_processor(processor)), + }; self } - /// Returns this [`VirtualDPad`] but with `left` and `right` swapped. - pub fn inverted_x(mut self) -> Self { - std::mem::swap(&mut self.left, &mut self.right); + /// Replaces the current [`DualAxisProcessor`] with the specified `processor`. + #[inline] + pub fn replace_processor(mut self, processor: impl DualAxisProcessor) -> Self { + self.processor = Some(Box::new(processor)); self } - /// Returns this [`VirtualDPad`] but with inverted inputs. - pub fn inverted(mut self) -> Self { - std::mem::swap(&mut self.up, &mut self.down); - std::mem::swap(&mut self.left, &mut self.right); + /// Remove the current used [`DualAxisProcessor`]. + #[inline] + pub fn no_processor(mut self) -> Self { + self.processor = None; self } + + /// Get the "value" of these axes. + /// If a processor is set, it will compute and return the processed value. + /// Otherwise, pass the `input_value` through unchanged. + #[must_use] + #[inline] + pub fn input_value(&self, input_value: Vec2) -> Vec2 { + match &self.processor { + Some(processor) => processor.process(input_value), + _ => input_value, + } + } } /// A virtual Axis that you can get a value between -1 and 1 from. @@ -442,6 +452,8 @@ pub struct VirtualAxis { pub negative: InputKind, /// The input that represents the positive direction of this virtual axis pub positive: InputKind, + /// The processor used to handle input values. + pub processor: Option>, } impl VirtualAxis { @@ -451,6 +463,7 @@ impl VirtualAxis { VirtualAxis { negative: InputKind::PhysicalKey(negative), positive: InputKind::PhysicalKey(positive), + processor: None, } } @@ -480,6 +493,7 @@ impl VirtualAxis { VirtualAxis { negative: InputKind::GamepadButton(GamepadButtonType::DPadLeft), positive: InputKind::GamepadButton(GamepadButtonType::DPadRight), + processor: None, } } @@ -489,6 +503,7 @@ impl VirtualAxis { VirtualAxis { negative: InputKind::GamepadButton(GamepadButtonType::DPadDown), positive: InputKind::GamepadButton(GamepadButtonType::DPadUp), + processor: None, } } @@ -497,6 +512,7 @@ impl VirtualAxis { VirtualAxis { negative: InputKind::GamepadButton(GamepadButtonType::West), positive: InputKind::GamepadButton(GamepadButtonType::East), + processor: None, } } @@ -505,15 +521,46 @@ impl VirtualAxis { VirtualAxis { negative: InputKind::GamepadButton(GamepadButtonType::South), positive: InputKind::GamepadButton(GamepadButtonType::North), + processor: None, } } - /// Returns this [`VirtualAxis`] but with flipped positive/negative inputs. - #[must_use] - pub fn inverted(mut self) -> Self { - std::mem::swap(&mut self.positive, &mut self.negative); + /// Appends the given [`AxisProcessor`] into the current [`AxisProcessingPipeline`], + /// or creates a new pipeline if one doesn't exist. + #[inline] + pub fn with_processor(mut self, processor: impl AxisProcessor) -> Self { + self.processor = match self.processor { + None => Some(Box::new(processor)), + Some(current_processor) => Some(current_processor.with_processor(processor)), + }; self } + + /// Replaces the current [`AxisProcessor`] with the specified `processor`. + #[inline] + pub fn replace_processor(mut self, processor: impl AxisProcessor) -> Self { + self.processor = Some(Box::new(processor)); + self + } + + /// Removes the current used [`AxisProcessor`]. + #[inline] + pub fn no_processor(mut self) -> Self { + self.processor = None; + self + } + + /// Get the "value" of the axis. + /// If a processor is set, it will compute and return the processed value. + /// Otherwise, pass the `input_value` through unchanged. + #[must_use] + #[inline] + pub fn input_value(&self, input_value: f32) -> f32 { + match &self.processor { + Some(processor) => processor.process(input_value), + _ => input_value, + } + } } /// The type of axis used by a [`UserInput`](crate::user_input::UserInput). @@ -725,117 +772,3 @@ impl From for Vec2 { data.xy } } - -/// The shape of the deadzone for a [`DualAxis`] input. -/// -/// Input values that are on the boundary of the shape are counted as inside. -/// If the 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 an ellipse deadzone with 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 horizontal and vertical rectangles. - /// Each axis is handled separately which creates a per-axis "snapping" effect. - Cross { - /// The width of the horizontal 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 { - /// The horizontal radius of the ellipse. - radius_x: f32, - /// The vertical radius of the ellipse. - radius_y: f32, - }, -} - -impl Eq for DeadZoneShape {} - -impl std::hash::Hash for DeadZoneShape { - fn hash(&self, state: &mut H) { - match self { - DeadZoneShape::Cross { - horizontal_width, - vertical_width, - } => { - FloatOrd(*horizontal_width).hash(state); - FloatOrd(*vertical_width).hash(state); - } - DeadZoneShape::Ellipse { radius_x, radius_y } => { - FloatOrd(*radius_x).hash(state); - FloatOrd(*radius_y).hash(state); - } - } - } -} - -impl DeadZoneShape { - /// Computes the input value based on the deadzone. - pub fn deadzone_input_value(&self, x: f32, y: f32) -> Option { - match self { - DeadZoneShape::Cross { - horizontal_width, - vertical_width, - } => self.cross_deadzone_value(x, y, *horizontal_width, *vertical_width), - DeadZoneShape::Ellipse { radius_x, radius_y } => { - self.ellipse_deadzone_value(x, y, *radius_x, *radius_y) - } - } - } - - /// Computes the input value based on the cross deadzone. - fn cross_deadzone_value( - &self, - x: f32, - y: f32, - horizontal_width: f32, - vertical_width: f32, - ) -> Option { - let new_x = deadzone_axis_value(x, vertical_width); - let new_y = deadzone_axis_value(y, horizontal_width); - let is_outside_deadzone = new_x != 0.0 || new_y != 0.0; - is_outside_deadzone.then(|| DualAxisData::new(new_x, new_y)) - } - - /// Computes the input value based on the ellipse deadzone. - fn ellipse_deadzone_value( - &self, - x: f32, - y: f32, - radius_x: f32, - radius_y: f32, - ) -> Option { - let x_ratio = x / radius_x.max(f32::EPSILON); - let y_ratio = y / radius_y.max(f32::EPSILON); - let is_outside_deadzone = x_ratio.powi(2) + y_ratio.powi(2) >= 1.0; - is_outside_deadzone.then(|| { - let new_x = deadzone_axis_value(x, radius_x); - let new_y = deadzone_axis_value(y, radius_y); - DualAxisData::new(new_x, new_y) - }) - } -} - -/// Applies the given deadzone to the axis value. -/// -/// Returns 0.0 if the axis value is within the deadzone. -/// Otherwise, returns the normalized axis value between -1.0 and 1.0. -pub(crate) fn deadzone_axis_value(axis_value: f32, deadzone: f32) -> f32 { - let abs_axis_value = axis_value.abs(); - if abs_axis_value <= deadzone { - 0.0 - } else { - axis_value.signum() * (abs_axis_value - deadzone) / (1.0 - deadzone) - } -} diff --git a/src/clashing_inputs.rs b/src/clashing_inputs.rs index 8dbda767..93438c5f 100644 --- a/src/clashing_inputs.rs +++ b/src/clashing_inputs.rs @@ -198,7 +198,7 @@ fn dpad_chord_clash(dpad: &VirtualDPad, chord: &[InputKind]) -> bool { chord.len() > 1 && chord .iter() - .any(|button| [dpad.up, dpad.down, dpad.left, dpad.right].contains(button)) + .any(|button| [&dpad.up, &dpad.down, &dpad.left, &dpad.right].contains(&button)) } #[must_use] @@ -352,6 +352,7 @@ mod tests { down: ArrowDown.into(), left: ArrowLeft.into(), right: ArrowRight.into(), + processor: None, }, ); input_map.insert_chord(CtrlUp, [ControlLeft, ArrowUp]); @@ -360,7 +361,6 @@ mod tests { } mod basic_functionality { - use crate::axislike::VirtualDPad; use crate::input_mocking::MockInput; use bevy::input::InputPlugin; use Action::*; @@ -380,6 +380,7 @@ mod tests { down: KeyX.into(), left: KeyY.into(), right: KeyZ.into(), + processor: None, } .into(); let abcd_dpad: UserInput = VirtualDPad { @@ -387,6 +388,7 @@ mod tests { down: KeyB.into(), left: KeyC.into(), right: KeyD.into(), + processor: None, } .into(); @@ -396,6 +398,7 @@ mod tests { down: ArrowDown.into(), left: ArrowLeft.into(), right: ArrowRight.into(), + processor: None, } .into(); diff --git a/src/display_impl.rs b/src/display_impl.rs index 1783bbf8..5c01b880 100644 --- a/src/display_impl.rs +++ b/src/display_impl.rs @@ -17,13 +17,16 @@ impl Display for UserInput { down, left, right, + .. }) => { write!( f, "VirtualDPad(up: {up}, down: {down}, left: {left}, right: {right})" ) } - UserInput::VirtualAxis(VirtualAxis { negative, positive }) => { + UserInput::VirtualAxis(VirtualAxis { + negative, positive, .. + }) => { write!(f, "VirtualDPad(negative: {negative}, positive: {positive})") } } diff --git a/src/input_map.rs b/src/input_map.rs index 6aff5867..9b8aeb4e 100644 --- a/src/input_map.rs +++ b/src/input_map.rs @@ -328,7 +328,7 @@ impl InputMap { if input_streams.input_pressed(input) { action_datum.state = ButtonState::JustPressed; - action_datum.value += input_streams.input_value(input, true); + action_datum.value += input_streams.input_value(input); } } diff --git a/src/input_processing/dual_axis/circle.rs b/src/input_processing/dual_axis/circle.rs new file mode 100644 index 00000000..27bd4bbd --- /dev/null +++ b/src/input_processing/dual_axis/circle.rs @@ -0,0 +1,520 @@ +//! Circular range processors for dual-axis inputs + +use std::fmt::Debug; +use std::hash::{Hash, Hasher}; + +use bevy::prelude::*; +use bevy::utils::FloatOrd; +use leafwing_input_manager_macros::serde_typetag; +use serde::{Deserialize, Serialize}; + +use super::DualAxisProcessor; +use crate as leafwing_input_manager; + +/// Specifies a circular region defining acceptable ranges for valid dual-axis inputs, +/// with a radius defining the maximum threshold magnitude, +/// restricting all values stay within intended limits +/// to avoid unexpected behavior caused by extreme inputs. +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// // Restrict magnitudes to no greater than 2 +/// let bounds = CircleBounds::new(2.0); +/// +/// for x in -300..300 { +/// let x = x as f32 * 0.01; +/// for y in -300..300 { +/// let y = y as f32 * 0.01; +/// let value = Vec2::new(x, y); +/// assert_eq!(bounds.process(value), value.clamp_length_max(2.0)); +/// } +/// } +/// ``` +#[doc(alias = "RadialBounds")] +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct CircleBounds { + /// The maximum radius of the circle. + pub(crate) radius: f32, +} + +#[serde_typetag] +impl DualAxisProcessor for CircleBounds { + /// Clamps the magnitude of `input_value` within the bounds. + #[must_use] + #[inline] + fn process(&self, input_value: Vec2) -> Vec2 { + input_value.clamp_length_max(self.radius) + } +} + +impl Default for CircleBounds { + /// Creates a [`CircleBounds`] that restricts the values to a maximum magnitude of `1.0`. + #[inline] + fn default() -> Self { + Self::new(1.0) + } +} + +impl CircleBounds { + /// Unlimited [`CircleBounds`]. + pub const FULL_RANGE: Self = Self { radius: f32::MAX }; + + /// Creates a [`CircleBounds`] that restricts input values to a maximum magnitude. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "magnitude")] + #[doc(alias = "from_radius")] + #[inline] + pub fn new(threshold: f32) -> Self { + assert!(threshold >= 0.0); + Self { radius: threshold } + } + + /// Returns the radius of the bounds. + #[must_use] + #[inline] + pub fn radius(&self) -> f32 { + self.radius + } + + /// Is the `input_value` is within the bounds? + #[must_use] + #[inline] + pub fn contains(&self, input_value: Vec2) -> bool { + input_value.length() <= self.radius + } +} + +impl Eq for CircleBounds {} + +impl Hash for CircleBounds { + fn hash(&self, state: &mut H) { + FloatOrd(self.radius).hash(state); + } +} + +/// Specifies a cross-shaped region for excluding dual-axis inputs, +/// with a radius defining the maximum excluded magnitude, +/// helping filter out minor fluctuations and unintended movements. +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// // Exclude magnitudes less than or equal to 0.2 +/// let exclusion = CircleExclusion::new(0.2); +/// +/// for x in -300..300 { +/// let x = x as f32 * 0.01; +/// for y in -300..300 { +/// let y = y as f32 * 0.01; +/// let value = Vec2::new(x, y); +/// +/// if value.length() <= 0.2 { +/// assert!(exclusion.contains(value)); +/// assert_eq!(exclusion.process(value), Vec2::ZERO); +/// } else { +/// assert!(!exclusion.contains(value)); +/// assert_eq!(exclusion.process(value), value); +/// } +/// } +/// } +/// ``` +#[doc(alias = "RadialExclusion")] +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct CircleExclusion { + /// Pre-calculated squared radius of the circle, preventing redundant calculations. + pub(crate) radius_squared: f32, +} + +#[serde_typetag] +impl DualAxisProcessor for CircleExclusion { + /// Excludes input values with a magnitude less than the `radius`. + #[must_use] + #[inline] + fn process(&self, input_value: Vec2) -> Vec2 { + if self.contains(input_value) { + Vec2::ZERO + } else { + input_value + } + } +} + +impl Default for CircleExclusion { + /// Creates a [`CircleExclusion`] that ignores input values below a minimum magnitude of `0.1`. + fn default() -> Self { + Self::new(0.1) + } +} + +impl CircleExclusion { + /// Zero-size [`CircleExclusion`], leaving values as is. + pub const ZERO: Self = Self { + radius_squared: 0.0, + }; + + /// Creates a [`CircleExclusion`] that ignores input values below a minimum magnitude. + /// + /// # Requirements + /// + /// - `radius` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "magnitude")] + #[doc(alias = "from_radius")] + #[inline] + pub fn new(threshold: f32) -> Self { + assert!(threshold >= 0.0); + Self { + radius_squared: threshold.powi(2), + } + } + + /// Returns the radius of the circle. + #[must_use] + #[inline] + pub fn radius(&self) -> f32 { + self.radius_squared.sqrt() + } + + /// Checks whether the `input_value` should be excluded. + #[must_use] + #[inline] + pub fn contains(&self, input_value: Vec2) -> bool { + input_value.length_squared() <= self.radius_squared + } + + /// Creates a [`CircleDeadZone`] using `self` as the exclusion range. + #[inline] + pub fn scaled(self) -> CircleDeadZone { + self.into() + } +} + +impl Eq for CircleExclusion {} + +impl Hash for CircleExclusion { + fn hash(&self, state: &mut H) { + FloatOrd(self.radius_squared).hash(state); + } +} + +/// A scaled version of [`CircleExclusion`] with the bounds +/// set to [`CircleBounds::new(1.0)`](CircleBounds::default) +/// that normalizes non-excluded input values into the "live zone", +/// the remaining range within the bounds after dead zone exclusion. +/// +/// It is worth considering that this normalizer reduces input values on diagonals. +/// If that is not your goal, you might want to explore alternative normalizers. +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// // Exclude magnitudes less than or equal to 0.2 +/// let deadzone = CircleDeadZone::new(0.2); +/// +/// for x in -300..300 { +/// let x = x as f32 * 0.01; +/// for y in -300..300 { +/// let y = y as f32 * 0.01; +/// let value = Vec2::new(x, y); +/// +/// // Values within the dead zone are treated as zeros. +/// if value.length() <= 0.2 { +/// assert!(deadzone.within_exclusion(value)); +/// assert_eq!(deadzone.process(value), Vec2::ZERO); +/// } +/// +/// // Values within the live zone are scaled linearly. +/// else if value.length() <= 1.0 { +/// assert!(deadzone.within_livezone(value)); +/// +/// let expected_scale = f32::inverse_lerp(0.2, 1.0, value.length()); +/// let expected = value.normalize() * expected_scale; +/// let delta = (deadzone.process(value) - expected).abs(); +/// assert!(delta.x <= 0.00001); +/// assert!(delta.y <= 0.00001); +/// } +/// +/// // Values outside the bounds are restricted to the region. +/// else { +/// assert!(!deadzone.within_bounds(value)); +/// +/// let expected = value.clamp_length_max(1.0); +/// let delta = (deadzone.process(value) - expected).abs(); +/// assert!(delta.x <= 0.00001); +/// assert!(delta.y <= 0.00001); +/// } +/// } +/// } +/// ``` +#[doc(alias = "RadialDeadZone")] +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct CircleDeadZone { + /// The radius of the circle. + pub(crate) radius: f32, + + /// Pre-calculated reciprocal of the live zone size, preventing division during normalization. + pub(crate) livezone_recip: f32, +} + +#[serde_typetag] +impl DualAxisProcessor for CircleDeadZone { + /// Normalizes input values into the live zone. + #[must_use] + fn process(&self, input_value: Vec2) -> Vec2 { + let input_length = input_value.length(); + if input_length == 0.0 { + return Vec2::ZERO; + } + + // Clamp out-of-bounds values to a maximum magnitude of 1, + // and then exclude values within the dead zone, + // and finally linearly scale the result to the live zone. + let (deadzone, bound) = self.livezone_min_max(); + let clamped_input_length = input_length.min(bound); + let offset_to_deadzone = (clamped_input_length - deadzone).max(0.0); + let magnitude_scale = (offset_to_deadzone * self.livezone_recip) / input_length; + input_value * magnitude_scale + } +} + +impl Default for CircleDeadZone { + /// Creates a [`CircleDeadZone`] that excludes input values below a minimum magnitude of `0.1`. + #[inline] + fn default() -> Self { + CircleExclusion::default().into() + } +} + +impl CircleDeadZone { + /// Zero-size [`CircleDeadZone`], only restricting values to a maximum magnitude of `1.0`. + pub const ZERO: Self = Self { + radius: 0.0, + livezone_recip: 1.0, + }; + + /// Creates a [`CircleDeadZone`] that excludes input values below a minimum magnitude. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "magnitude")] + #[doc(alias = "from_radius")] + #[inline] + pub fn new(threshold: f32) -> Self { + let bounds = CircleBounds::default(); + Self { + radius: threshold, + livezone_recip: (bounds.radius - threshold).recip(), + } + } + + /// Returns the radius of the circle. + #[must_use] + #[inline] + pub fn radius(&self) -> f32 { + self.radius + } + + /// Returns the [`CircleExclusion`] used by this deadzone. + #[inline] + pub fn exclusion(&self) -> CircleExclusion { + CircleExclusion::new(self.radius) + } + + /// Returns the [`CircleBounds`] used by this deadzone. + #[inline] + pub fn bounds(&self) -> CircleBounds { + CircleBounds::default() + } + + /// Returns the minimum and maximum radii of the live zone used by this deadzone. + /// + /// In simple terms, this returns `(self.radius, bounds.radius)`. + #[must_use] + #[inline] + pub fn livezone_min_max(&self) -> (f32, f32) { + (self.radius, self.bounds().radius) + } + + /// Is the given `input_value` within the exclusion range? + #[must_use] + #[inline] + pub fn within_exclusion(&self, input_value: Vec2) -> bool { + self.exclusion().contains(input_value) + } + + /// Is the given `input_value` within the bounds? + #[must_use] + #[inline] + pub fn within_bounds(&self, input_value: Vec2) -> bool { + self.bounds().contains(input_value) + } + + /// Is the given `input_value` within the live zone? + #[must_use] + #[inline] + pub fn within_livezone(&self, input_value: Vec2) -> bool { + let input_length = input_value.length(); + let (min, max) = self.livezone_min_max(); + min <= input_length && input_length <= max + } +} + +impl From for CircleDeadZone { + fn from(exclusion: CircleExclusion) -> Self { + Self::new(exclusion.radius()) + } +} + +impl Eq for CircleDeadZone {} + +impl Hash for CircleDeadZone { + fn hash(&self, state: &mut H) { + FloatOrd(self.radius).hash(state); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_circle_value_bounds() { + fn test_bounds(bounds: CircleBounds, radius: f32) { + assert_eq!(bounds.radius(), radius); + + for x in -300..300 { + let x = x as f32 * 0.01; + for y in -300..300 { + let y = y as f32 * 0.01; + let value = Vec2::new(x, y); + + if value.length() <= radius { + assert!(bounds.contains(value)); + } else { + assert!(!bounds.contains(value)); + } + + let expected = value.clamp_length_max(radius); + let delta = (bounds.process(value) - expected).abs(); + assert!(delta.x <= f32::EPSILON); + assert!(delta.y <= f32::EPSILON); + } + } + } + + let bounds = CircleBounds::FULL_RANGE; + test_bounds(bounds, f32::MAX); + + let bounds = CircleBounds::default(); + test_bounds(bounds, 1.0); + + let bounds = CircleBounds::new(2.0); + test_bounds(bounds, 2.0); + } + + #[test] + fn test_circle_exclusion() { + fn test_exclusion(exclusion: CircleExclusion, radius: f32) { + assert_eq!(exclusion.radius(), radius); + + for x in -300..300 { + let x = x as f32 * 0.01; + for y in -300..300 { + let y = y as f32 * 0.01; + let value = Vec2::new(x, y); + + if value.length() <= radius { + assert!(exclusion.contains(value)); + assert_eq!(exclusion.process(value), Vec2::ZERO); + } else { + assert!(!exclusion.contains(value)); + assert_eq!(exclusion.process(value), value); + } + } + } + } + + let exclusion = CircleExclusion::ZERO; + test_exclusion(exclusion, 0.0); + + let exclusion = CircleExclusion::default(); + test_exclusion(exclusion, 0.1); + + let exclusion = CircleExclusion::new(0.5); + test_exclusion(exclusion, 0.5); + } + + #[test] + fn test_circle_deadzone() { + fn test_deadzone(deadzone: CircleDeadZone, radius: f32) { + assert_eq!(deadzone.radius(), radius); + + let exclusion = CircleExclusion::new(radius); + assert_eq!(exclusion.scaled(), deadzone); + + for x in -300..300 { + let x = x as f32 * 0.01; + for y in -300..300 { + let y = y as f32 * 0.01; + let value = Vec2::new(x, y); + + // Values within the dead zone are treated as zeros. + if value.length() <= radius { + assert!(deadzone.within_exclusion(value)); + assert_eq!(deadzone.process(value), Vec2::ZERO); + } + // Values within the live zone are scaled linearly. + else if value.length() <= 1.0 { + assert!(deadzone.within_livezone(value)); + + let expected_scale = f32::inverse_lerp(radius, 1.0, value.length()); + let expected = value.normalize() * expected_scale; + let delta = (deadzone.process(value) - expected).abs(); + assert!(delta.x <= 0.00001); + assert!(delta.y <= 0.00001); + } + // Values outside the bounds are restricted to the region. + else { + assert!(!deadzone.within_bounds(value)); + + let expected = value.clamp_length_max(1.0); + let delta = (deadzone.process(value) - expected).abs(); + assert!(delta.x <= 0.00001); + assert!(delta.y <= 0.00001); + } + } + } + } + + let deadzone = CircleDeadZone::ZERO; + test_deadzone(deadzone, 0.0); + + let deadzone = CircleDeadZone::default(); + test_deadzone(deadzone, 0.1); + + let deadzone = CircleDeadZone::new(0.5); + test_deadzone(deadzone, 0.5); + } +} diff --git a/src/input_processing/dual_axis/mod.rs b/src/input_processing/dual_axis/mod.rs new file mode 100644 index 00000000..c302c8da --- /dev/null +++ b/src/input_processing/dual_axis/mod.rs @@ -0,0 +1,349 @@ +//! Processors for dual-axis input values + +use std::any::{Any, TypeId}; +use std::fmt::{Debug, Formatter}; +use std::hash::{Hash, Hasher}; +use std::sync::RwLock; + +use bevy::prelude::{App, Vec2}; +use bevy::reflect::utility::{reflect_hasher, GenericTypePathCell, NonGenericTypeInfoCell}; +use bevy::reflect::{ + erased_serde, FromReflect, FromType, GetTypeRegistration, Reflect, ReflectDeserialize, + ReflectFromPtr, ReflectKind, ReflectMut, ReflectOwned, ReflectRef, ReflectSerialize, TypeInfo, + TypePath, TypeRegistration, Typed, ValueInfo, +}; +use dyn_clone::DynClone; +use dyn_eq::DynEq; +use dyn_hash::DynHash; +use once_cell::sync::Lazy; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_flexitos::ser::require_erased_serialize_impl; +use serde_flexitos::{serialize_trait_object, MapRegistry, Registry}; + +use crate::typetag::RegisterTypeTag; + +pub use self::circle::*; +pub use self::modifier::*; +pub use self::pipeline::*; +pub use self::range::*; + +mod circle; +mod modifier; +mod pipeline; +mod range; + +/// A trait for processing dual-axis input values, +/// accepting a [`Vec2`] input and producing a [`Vec2`] output. +/// +/// # Examples +/// +/// ```rust +/// use std::hash::{Hash, Hasher}; +/// use bevy::prelude::*; +/// use bevy::utils::FloatOrd; +/// use serde::{Deserialize, Serialize}; +/// use leafwing_input_manager::prelude::*; +/// +/// /// Doubles the input, takes its absolute value, +/// /// and discards results that meet the specified condition on the X-axis. +/// // If your processor includes fields not implemented Eq and Hash, +/// // implementation is necessary as shown below. +/// // Otherwise, you can derive Eq and Hash directly. +/// #[derive(Debug, Clone, PartialEq, Reflect, Serialize, Deserialize)] +/// pub struct DoubleAbsoluteValueThenRejectX(pub f32); +/// +/// // Add this attribute for ensuring proper serialization and deserialization. +/// #[serde_typetag] +/// impl DualAxisProcessor for DoubleAbsoluteValueThenRejectX { +/// fn process(&self, input_value: Vec2) -> Vec2 { +/// // Implement the logic just like you would in a normal function. +/// +/// // You can use other processors within this function. +/// let value = DualAxisSensitivity::all(2.0).process(input_value); +/// +/// let value = value.abs(); +/// let new_x = if value.x == self.0 { +/// 0.0 +/// } else { +/// value.x +/// }; +/// Vec2::new(new_x, value.y) +/// } +/// } +/// +/// // Unfortunately, manual implementation is required due to the float field. +/// impl Eq for DoubleAbsoluteValueThenRejectX {} +/// impl Hash for DoubleAbsoluteValueThenRejectX { +/// fn hash(&self, state: &mut H) { +/// // Encapsulate the float field for hashing. +/// FloatOrd(self.0).hash(state); +/// } +/// } +/// +/// // Remember to register your processor - it will ensure everything works smoothly! +/// let mut app = App::new(); +/// app.register_dual_axis_processor::(); +/// +/// // Now you can use it! +/// let processor = DoubleAbsoluteValueThenRejectX(4.0); +/// +/// // Rejected X! +/// assert_eq!(processor.process(Vec2::splat(2.0)), Vec2::new(0.0, 4.0)); +/// assert_eq!(processor.process(Vec2::splat(-2.0)), Vec2::new(0.0, 4.0)); +/// +/// // Others are just doubled absolute value. +/// assert_eq!(processor.process(Vec2::splat(6.0)), Vec2::splat(12.0)); +/// assert_eq!(processor.process(Vec2::splat(4.0)), Vec2::splat(8.0)); +/// assert_eq!(processor.process(Vec2::splat(0.0)), Vec2::splat(0.0)); +/// assert_eq!(processor.process(Vec2::splat(-4.0)), Vec2::splat(8.0)); +/// assert_eq!(processor.process(Vec2::splat(-6.0)), Vec2::splat(12.0)); +/// ``` +pub trait DualAxisProcessor: + Send + Sync + Debug + DynClone + DynEq + DynHash + Reflect + erased_serde::Serialize +{ + /// Computes the result by processing the `input_value`. + fn process(&self, input_value: Vec2) -> Vec2; +} + +dyn_clone::clone_trait_object!(DualAxisProcessor); +dyn_eq::eq_trait_object!(DualAxisProcessor); +dyn_hash::hash_trait_object!(DualAxisProcessor); + +impl Reflect for Box { + fn get_represented_type_info(&self) -> Option<&'static TypeInfo> { + Some(Self::type_info()) + } + + fn into_any(self: Box) -> Box { + self + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn into_reflect(self: Box) -> Box { + self + } + + fn as_reflect(&self) -> &dyn Reflect { + self + } + + fn as_reflect_mut(&mut self) -> &mut dyn Reflect { + self + } + + fn apply(&mut self, value: &dyn Reflect) { + let value = value.as_any(); + if let Some(value) = value.downcast_ref::() { + *self = value.clone(); + } else { + panic!( + "Value is not a std::boxed::Box.", + module_path!(), + ); + } + } + + fn set(&mut self, value: Box) -> Result<(), Box> { + *self = value.take()?; + Ok(()) + } + + fn reflect_kind(&self) -> ReflectKind { + ReflectKind::Value + } + + fn reflect_ref(&self) -> ReflectRef { + ReflectRef::Value(self) + } + + fn reflect_mut(&mut self) -> ReflectMut { + ReflectMut::Value(self) + } + + fn reflect_owned(self: Box) -> ReflectOwned { + ReflectOwned::Value(self) + } + + fn clone_value(&self) -> Box { + Box::new(self.clone()) + } + + fn reflect_hash(&self) -> Option { + let mut hasher = reflect_hasher(); + let type_id = TypeId::of::>(); + Hash::hash(&type_id, &mut hasher); + Hash::hash(self, &mut hasher); + Some(hasher.finish()) + } + + fn reflect_partial_eq(&self, value: &dyn Reflect) -> Option { + let value = value.as_any(); + value + .downcast_ref::() + .map(|value| self.dyn_eq(value)) + .or(Some(false)) + } + + fn debug(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self, f) + } +} + +impl Typed for Box { + fn type_info() -> &'static TypeInfo { + static CELL: NonGenericTypeInfoCell = NonGenericTypeInfoCell::new(); + CELL.get_or_set(|| TypeInfo::Value(ValueInfo::new::())) + } +} + +impl TypePath for Box { + fn type_path() -> &'static str { + static CELL: GenericTypePathCell = GenericTypePathCell::new(); + CELL.get_or_insert::(|| { + { + format!("std::boxed::Box(dyn {}::DualAxisProcessor)", module_path!()) + } + }) + } + + fn short_type_path() -> &'static str { + static CELL: GenericTypePathCell = GenericTypePathCell::new(); + CELL.get_or_insert::(|| "Box(dyn DualAxisProcessor)".to_string()) + } + + fn type_ident() -> Option<&'static str> { + Some("DualAxisProcessor") + } + + fn crate_name() -> Option<&'static str> { + Some(module_path!().split(':').next().unwrap()) + } + + fn module_path() -> Option<&'static str> { + Some(module_path!()) + } +} + +impl GetTypeRegistration for Box { + fn get_type_registration() -> TypeRegistration { + let mut registration = TypeRegistration::of::(); + registration.insert::(FromType::::from_type()); + registration.insert::(FromType::::from_type()); + registration.insert::(FromType::::from_type()); + registration + } +} + +impl FromReflect for Box { + fn from_reflect(reflect: &dyn Reflect) -> Option { + Some(reflect.as_any().downcast_ref::()?.clone()) + } +} + +impl<'a> Serialize for dyn DualAxisProcessor + 'a { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Check that `DualAxisProcessor` has `erased_serde::Serialize` as a super trait, preventing infinite recursion at runtime. + const fn __check_erased_serialize_super_trait() { + require_erased_serialize_impl::(); + } + serialize_trait_object(serializer, self.reflect_short_type_path(), self) + } +} + +impl<'de> Deserialize<'de> for Box { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let registry = unsafe { PROCESSOR_REGISTRY.read().unwrap() }; + registry.deserialize_trait_object(deserializer) + } +} + +/// Registry of deserializers for [`DualAxisProcessor`]s. +static mut PROCESSOR_REGISTRY: Lazy>> = + Lazy::new(|| RwLock::new(MapRegistry::new("DualAxisProcessor"))); + +/// A trait for registering a specific [`DualAxisProcessor`]. +pub trait RegisterDualAxisProcessor { + /// Registers the specified [`DualAxisProcessor`]. + fn register_dual_axis_processor<'de, T>(&mut self) -> &mut Self + where + T: RegisterTypeTag<'de, dyn DualAxisProcessor> + GetTypeRegistration; +} + +impl RegisterDualAxisProcessor for App { + #[allow(unsafe_code)] + fn register_dual_axis_processor<'de, T>(&mut self) -> &mut Self + where + T: RegisterTypeTag<'de, dyn DualAxisProcessor> + GetTypeRegistration, + { + let mut registry = unsafe { PROCESSOR_REGISTRY.write().unwrap() }; + T::register_typetag(&mut registry); + self.register_type::(); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_test::{assert_tokens, Token}; + + #[test] + fn test_serde_dual_axis_processor() { + let mut app = App::new(); + app.register_dual_axis_processor::(); + app.register_dual_axis_processor::(); + + let inversion: Box = Box::new(DualAxisInverted::ALL); + assert_tokens( + &inversion, + &[ + Token::Map { len: Some(1) }, + Token::BorrowedStr("DualAxisInverted"), + Token::NewtypeStruct { + name: "DualAxisInverted", + }, + Token::TupleStruct { + name: "Vec2", + len: 2, + }, + Token::F32(-1.0), + Token::F32(-1.0), + Token::TupleStructEnd, + Token::MapEnd, + ], + ); + + let sensitivity: Box = Box::new(DualAxisSensitivity::only_x(5.0)); + assert_tokens( + &sensitivity, + &[ + Token::Map { len: Some(1) }, + Token::BorrowedStr("DualAxisSensitivity"), + Token::NewtypeStruct { + name: "DualAxisSensitivity", + }, + Token::TupleStruct { + name: "Vec2", + len: 2, + }, + Token::F32(5.0), + Token::F32(1.0), + Token::TupleStructEnd, + Token::MapEnd, + ], + ); + } +} diff --git a/src/input_processing/dual_axis/modifier.rs b/src/input_processing/dual_axis/modifier.rs new file mode 100644 index 00000000..dd468f61 --- /dev/null +++ b/src/input_processing/dual_axis/modifier.rs @@ -0,0 +1,225 @@ +//! Modifiers for dual-axis inputs + +use std::fmt::Debug; +use std::hash::{Hash, Hasher}; + +use bevy::prelude::{BVec2, Reflect, Vec2}; +use bevy::utils::FloatOrd; +use leafwing_input_manager_macros::serde_typetag; +use serde::{Deserialize, Serialize}; + +use crate as leafwing_input_manager; +use crate::input_processing::DualAxisProcessor; + +/// Flips the sign of dual-axis input values, resulting in a directional reversal of control. +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// let value = Vec2::new(1.5, 2.0); +/// let Vec2 { x, y } = value; +/// +/// assert_eq!(DualAxisInverted::ALL.process(value), -value); +/// assert_eq!(DualAxisInverted::ALL.process(-value), value); +/// +/// assert_eq!(DualAxisInverted::ONLY_X.process(value), Vec2::new(-x, y)); +/// assert_eq!(DualAxisInverted::ONLY_X.process(-value), Vec2::new(x, -y)); +/// +/// assert_eq!(DualAxisInverted::ONLY_Y.process(value), Vec2::new(x, -y)); +/// assert_eq!(DualAxisInverted::ONLY_Y.process(-value), Vec2::new(-x, y)); +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct DualAxisInverted(Vec2); + +#[serde_typetag] +impl DualAxisProcessor for DualAxisInverted { + /// Multiples the `input_value` by the specified inversion vector. + #[must_use] + #[inline] + fn process(&self, input_value: Vec2) -> Vec2 { + self.0 * input_value + } +} + +impl DualAxisInverted { + /// The [`DualAxisInverted`] that inverts both axes. + pub const ALL: Self = Self(Vec2::NEG_ONE); + + /// The [`DualAxisInverted`] that only inverts the X-axis inputs. + pub const ONLY_X: Self = Self(Vec2::new(-1.0, 1.0)); + + /// The [`DualAxisInverted`] that only inverts the Y-axis inputs. + pub const ONLY_Y: Self = Self(Vec2::new(1.0, -1.0)); + + /// Are inputs inverted on both axes? + #[must_use] + #[inline] + pub fn inverted(&self) -> BVec2 { + self.0.cmpeq(Vec2::NEG_ONE) + } +} + +impl Eq for DualAxisInverted {} + +impl Hash for DualAxisInverted { + fn hash(&self, state: &mut H) { + FloatOrd(self.0.x).hash(state); + FloatOrd(self.0.y).hash(state); + } +} + +/// Scales dual-axis input values using a specified multiplier, +/// allowing fine-tuning the responsiveness of controls. +/// +/// # Examples +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// let value = Vec2::new(1.5, 2.5); +/// let Vec2 { x, y } = value; +/// +/// // Negated X and halved Y +/// let neg_x_half_y = DualAxisSensitivity::new(-1.0, 0.5); +/// assert_eq!(neg_x_half_y.process(value).x, -x); +/// assert_eq!(neg_x_half_y.process(value).y, 0.5 * y); +/// +/// // Doubled X and doubled Y +/// let double = DualAxisSensitivity::all(2.0); +/// assert_eq!(double.process(value), 2.0 * value); +/// +/// // Halved X +/// let half_x = DualAxisSensitivity::only_x(0.5); +/// assert_eq!(half_x.process(value).x, 0.5 * x); +/// assert_eq!(half_x.process(value).y, y); +/// +/// // Negated and doubled Y +/// let neg_double_y = DualAxisSensitivity::only_y(-2.0); +/// assert_eq!(neg_double_y.process(value).x, x); +/// assert_eq!(neg_double_y.process(value).y, -2.0 * y); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct DualAxisSensitivity(pub(crate) Vec2); + +#[serde_typetag] +impl DualAxisProcessor for DualAxisSensitivity { + /// Multiples the `input_value` by the specified sensitivity vector. + #[must_use] + #[inline] + fn process(&self, input_value: Vec2) -> Vec2 { + self.0 * input_value + } +} + +impl DualAxisSensitivity { + /// Creates a [`DualAxisSensitivity`] with the given values for each axis separately. + #[inline] + pub const fn new(sensitivity_x: f32, sensitivity_y: f32) -> Self { + Self(Vec2::new(sensitivity_x, sensitivity_y)) + } + + /// Creates a [`DualAxisSensitivity`] with the same value for both axes. + #[inline] + pub const fn all(sensitivity: f32) -> Self { + Self::new(sensitivity, sensitivity) + } + + /// Creates a [`DualAxisSensitivity`] that only affects the X-axis using the given value. + #[inline] + pub const fn only_x(sensitivity: f32) -> Self { + Self::new(sensitivity, 1.0) + } + + /// Creates a [`DualAxisSensitivity`] that only affects the Y-axis using the given value. + #[inline] + pub const fn only_y(sensitivity: f32) -> Self { + Self::new(1.0, sensitivity) + } + + /// Returns the sensitivity values. + #[must_use] + #[inline] + pub fn sensitivities(&self) -> Vec2 { + self.0 + } +} + +impl Eq for DualAxisSensitivity {} + +impl Hash for DualAxisSensitivity { + fn hash(&self, state: &mut H) { + FloatOrd(self.0.x).hash(state); + FloatOrd(self.0.y).hash(state); + } +} + +#[cfg(test)] +mod tests { + use crate::input_processing::dual_axis::*; + use bevy::math::BVec2; + + #[test] + fn test_dual_axis_inverted() { + assert_eq!(DualAxisInverted::ALL.inverted(), BVec2::TRUE); + assert_eq!(DualAxisInverted::ONLY_X.inverted(), BVec2::new(true, false)); + assert_eq!(DualAxisInverted::ONLY_Y.inverted(), BVec2::new(false, true)); + + for x in -300..300 { + let x = x as f32 * 0.01; + + for y in -300..300 { + let y = y as f32 * 0.01; + let value = Vec2::new(x, y); + + assert_eq!(DualAxisInverted::ALL.process(value), -value); + assert_eq!(DualAxisInverted::ALL.process(-value), value); + + assert_eq!(DualAxisInverted::ONLY_X.process(value), Vec2::new(-x, y)); + assert_eq!(DualAxisInverted::ONLY_X.process(-value), Vec2::new(x, -y)); + + assert_eq!(DualAxisInverted::ONLY_Y.process(value), Vec2::new(x, -y)); + assert_eq!(DualAxisInverted::ONLY_Y.process(-value), Vec2::new(-x, y)); + } + } + } + + #[test] + fn test_dual_axis_sensitivity() { + for x in -300..300 { + let x = x as f32 * 0.01; + + for y in -300..300 { + let y = y as f32 * 0.01; + let value = Vec2::new(x, y); + + let sensitivity = x; + + let all = DualAxisSensitivity::all(sensitivity); + assert_eq!(all.sensitivities(), Vec2::splat(sensitivity)); + assert_eq!(all.process(value), sensitivity * value); + + let only_x = DualAxisSensitivity::only_x(sensitivity); + assert_eq!(only_x.sensitivities(), Vec2::new(sensitivity, 1.0)); + assert_eq!(only_x.process(value).x, x * sensitivity); + assert_eq!(only_x.process(value).y, y); + + let only_y = DualAxisSensitivity::only_y(sensitivity); + assert_eq!(only_y.sensitivities(), Vec2::new(1.0, sensitivity)); + assert_eq!(only_y.process(value).x, x); + assert_eq!(only_y.process(value).y, y * sensitivity); + + let sensitivity2 = y; + let separate = DualAxisSensitivity::new(sensitivity, sensitivity2); + assert_eq!( + separate.sensitivities(), + Vec2::new(sensitivity, sensitivity2) + ); + assert_eq!(separate.process(value).x, x * sensitivity); + assert_eq!(separate.process(value).y, y * sensitivity2); + } + } + } +} diff --git a/src/input_processing/dual_axis/pipeline.rs b/src/input_processing/dual_axis/pipeline.rs new file mode 100644 index 00000000..309e4c63 --- /dev/null +++ b/src/input_processing/dual_axis/pipeline.rs @@ -0,0 +1,166 @@ +//! Processing pipeline for dual-axis inputs. + +use bevy::prelude::{Reflect, Vec2}; +use leafwing_input_manager_macros::serde_typetag; +use serde::{Deserialize, Serialize}; + +use super::DualAxisProcessor; +use crate as leafwing_input_manager; + +/// A dynamic sequence container of [`DualAxisProcessor`]s designed for processing input values. +/// +/// # Warning +/// +/// This flexibility may hinder compiler optimizations such as inlining or dead code elimination. +/// For performance-critical scenarios, consider creating your own processors for improved performance. +/// +/// # Examples +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// let input_value = Vec2::splat(1.5); +/// +/// // Just a heads up, the default pipeline won't tweak values. +/// let pipeline = DualAxisProcessingPipeline::default(); +/// assert_eq!(pipeline.process(input_value), input_value); +/// +/// // You can link up a sequence of processors to make a pipeline. +/// let mut pipeline = DualAxisProcessingPipeline::default() +/// .with(DualAxisInverted::ALL) +/// .with(DualAxisSensitivity::all(2.0)); +/// +/// // Now it inverts and doubles values. +/// assert_eq!(pipeline.process(input_value), -2.0 * input_value); +/// +/// // You can also add a processor just like you would do with a Vec. +/// pipeline.push(DualAxisSensitivity::only_x(1.5)); +/// +/// // Now it inverts values and multiplies the results by [3.0, 2.0] +/// assert_eq!(pipeline.process(input_value), Vec2::new(-3.0, -2.0) * input_value); +/// +/// // Plus, you can switch out a processor at a specific index. +/// pipeline.set(1, DualAxisSensitivity::all(3.0)); +/// +/// // Now it inverts values and multiplies the results by [4.5, 3.0] +/// assert_eq!(pipeline.process(input_value), Vec2::new(-4.5, -3.0) * input_value); +/// +/// // If needed, you can remove all processors. +/// pipeline.clear(); +/// +/// // Now it just leaves values as is. +/// assert_eq!(pipeline.process(input_value), input_value); +/// ``` +#[must_use] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] +pub struct DualAxisProcessingPipeline(pub(crate) Vec>); + +#[serde_typetag] +impl DualAxisProcessor for DualAxisProcessingPipeline { + /// Computes the result by passing the `input_value` through this pipeline. + #[must_use] + #[inline] + fn process(&self, input_value: Vec2) -> Vec2 { + self.0 + .iter() + .fold(input_value, |value, next| next.process(value)) + } +} + +impl DualAxisProcessingPipeline { + /// Appends the given [`DualAxisProcessor`] into this pipeline and returns `self`. + #[inline] + pub fn with(mut self, processor: impl DualAxisProcessor) -> Self { + self.push(processor); + self + } + + /// Appends the given [`DualAxisProcessor`] into this pipeline. + #[inline] + pub fn push(&mut self, processor: impl DualAxisProcessor) { + self.0.push(Box::new(processor)); + } + + /// Replaces the processor at the `index` with the given [`DualAxisProcessor`]. + #[inline] + pub fn set(&mut self, index: usize, processor: impl DualAxisProcessor) { + self.0[index] = Box::new(processor); + } + + /// Removes all processors in this pipeline. + #[inline] + pub fn clear(&mut self) { + self.0.clear(); + } +} + +/// A trait for appending a [`DualAxisProcessor`] as the next processing step in the pipeline, +/// enabling further processing of input values. +pub trait WithDualAxisProcessor { + /// Appends a [`DualAxisProcessor`] as the next processing step in the pipeline. + fn with_processor(self, processor: impl DualAxisProcessor) -> Self; +} + +impl WithDualAxisProcessor for Box { + /// Creates a new boxed [`DualAxisProcessingPipeline`] with the existing steps + /// and appends the given [`DualAxisProcessor`]. + fn with_processor(self, processor: impl DualAxisProcessor) -> Self { + let pipeline = match Reflect::as_any(&*self).downcast_ref::() { + Some(pipeline) => pipeline.clone(), + None => DualAxisProcessingPipeline(vec![self]), + }; + Box::new(pipeline.with(processor)) + } +} + +#[cfg(test)] +mod tests { + use crate::input_processing::*; + use bevy::prelude::Vec2; + + #[test] + fn test_dual_axis_processing_pipeline() { + // Add processors to a new pipeline. + let mut pipeline = DualAxisProcessingPipeline::default() + .with(DualAxisSensitivity::all(4.0)) + .with(DualAxisInverted::ALL) + .with(DualAxisInverted::ALL); + + pipeline.push(DualAxisSensitivity::all(3.0)); + + pipeline.set(3, DualAxisSensitivity::all(6.0)); + + // This pipeline now scales input values by a factor of 24.0 + assert_eq!(pipeline.process(Vec2::splat(2.0)), Vec2::splat(48.0)); + assert_eq!(pipeline.process(Vec2::splat(-3.0)), Vec2::splat(-72.0)); + + // Now it just leaves values as is. + pipeline.clear(); + assert_eq!(pipeline, DualAxisProcessingPipeline::default()); + assert_eq!(pipeline.process(Vec2::splat(2.0)), Vec2::splat(2.0)); + } + + #[test] + fn test_with_axis_processor() { + let first = DualAxisSensitivity::all(2.0); + let first_boxed: Box = Box::new(first); + + let second = DualAxisSensitivity::all(3.0); + let merged_second = first_boxed.with_processor(second); + let expected = DualAxisProcessingPipeline::default() + .with(first) + .with(second); + let expected_boxed: Box = Box::new(expected); + assert_eq!(merged_second, expected_boxed); + + let third = DualAxisSensitivity::all(4.0); + let merged_third = merged_second.with_processor(third); + let expected = DualAxisProcessingPipeline::default() + .with(first) + .with(second) + .with(third); + let expected_boxed: Box = Box::new(expected); + assert_eq!(merged_third, expected_boxed); + } +} diff --git a/src/input_processing/dual_axis/range.rs b/src/input_processing/dual_axis/range.rs new file mode 100644 index 00000000..bf771bd5 --- /dev/null +++ b/src/input_processing/dual_axis/range.rs @@ -0,0 +1,1329 @@ +//! Range processors for dual-axis inputs + +use std::fmt::Debug; +use std::hash::Hash; + +use bevy::prelude::*; +use leafwing_input_manager_macros::serde_typetag; +use serde::{Deserialize, Serialize}; + +use super::DualAxisProcessor; +use crate as leafwing_input_manager; +use crate::input_processing::single_axis::*; + +/// Specifies a square-shaped region defining acceptable ranges for valid dual-axis inputs, +/// with independent min-max ranges for each axis, restricting all values stay within intended limits +/// to avoid unexpected behavior caused by extreme inputs. +/// +/// In simple terms, this processor is just the dual-axis version of [`AxisBounds`]. +/// Helpers like [`AxisBounds::extend_dual()`] and its peers can be used to create a [`DualAxisBounds`]. +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// // Restrict X to [-2.0, 2.5] and Y to [-1.0, 1.5]. +/// let bounds = DualAxisBounds::new((-2.0, 2.5), (-1.0, 1.5)); +/// assert_eq!(bounds.bounds_x().min_max(), (-2.0, 2.5)); +/// assert_eq!(bounds.bounds_y().min_max(), (-1.0, 1.5)); +/// +/// // Another way to create a DualAxisBounds. +/// let bounds_x = AxisBounds::new(-2.0, 2.5); +/// let bounds_y = AxisBounds::new(-1.0, 1.5); +/// assert_eq!(bounds_x.extend_dual_with_y(bounds_y), bounds); +/// +/// for x in -300..300 { +/// let x = x as f32 * 0.01; +/// for y in -300..300 { +/// let y = y as f32 * 0.01; +/// let value = Vec2::new(x, y); +/// +/// assert_eq!(bounds.process(value).x, bounds_x.process(x)); +/// assert_eq!(bounds.process(value).y, bounds_y.process(y)); +/// } +/// } +/// ``` +#[doc(alias("SquareBounds", "AxialBounds"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct DualAxisBounds { + /// The [`AxisBounds`] for the X-axis inputs. + pub(crate) bounds_x: AxisBounds, + + /// The [`AxisBounds`] for the Y-axis inputs. + pub(crate) bounds_y: AxisBounds, +} + +#[serde_typetag] +impl DualAxisProcessor for DualAxisBounds { + /// Clamps `input_value` within the bounds. + #[must_use] + #[inline] + fn process(&self, input_value: Vec2) -> Vec2 { + Vec2::new( + self.bounds_x.process(input_value.x), + self.bounds_y.process(input_value.y), + ) + } +} + +impl Default for DualAxisBounds { + /// Creates a [`DualAxisBounds`] that restricts values within the range `[-1.0, 1.0]` on both axes. + #[inline] + fn default() -> Self { + AxisBounds::default().extend_dual() + } +} + +impl DualAxisBounds { + /// Unlimited [`DualAxisBounds`]. + pub const FULL_RANGE: Self = AxisBounds::FULL_RANGE.extend_dual(); + + /// Creates a [`DualAxisBounds`] that restricts values within the range `[min, max]` on each axis. + /// + /// # Requirements + /// + /// - `min` <= `max` on each axis. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn new((x_min, x_max): (f32, f32), (y_min, y_max): (f32, f32)) -> Self { + Self { + bounds_x: AxisBounds::new(x_min, x_max), + bounds_y: AxisBounds::new(y_min, y_max), + } + } + + /// Creates a [`DualAxisBounds`] that restricts values within the same range `[min, max]` on both axes. + /// + /// # Requirements + /// + /// - `min` <= `max`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn all(min: f32, max: f32) -> Self { + let range = (min, max); + Self::new(range, range) + } + + /// Creates a [`DualAxisBounds`] that only restricts X values within the range `[min, max]`. + /// + /// # Requirements + /// + /// - `min` <= `max`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn only_x(min: f32, max: f32) -> Self { + Self { + bounds_x: AxisBounds::new(min, max), + ..Self::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] that only restricts Y values within the range `[min, max]`. + /// + /// # Requirements + /// + /// - `min` <= `max`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn only_y(min: f32, max: f32) -> Self { + Self { + bounds_y: AxisBounds::new(min, max), + ..Self::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] that restricts values within the range `[-threshold, threshold]` on each axis. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0` on each axis. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric")] + #[inline] + pub fn magnitude(threshold_x: f32, threshold_y: f32) -> Self { + Self { + bounds_x: AxisBounds::magnitude(threshold_x), + bounds_y: AxisBounds::magnitude(threshold_y), + } + } + + /// Creates a [`DualAxisBounds`] that restricts values within the range `[-threshold, threshold]` on both axes. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_all")] + #[inline] + pub fn magnitude_all(threshold: f32) -> Self { + Self::magnitude(threshold, threshold) + } + + /// Creates a [`DualAxisBounds`] that only restricts X values within the range `[-threshold, threshold]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_only_x")] + #[inline] + pub fn magnitude_only_x(threshold: f32) -> Self { + Self { + bounds_x: AxisBounds::magnitude(threshold), + ..Self::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] that only restricts Y values within the range `[-threshold, threshold]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_only_y")] + #[inline] + pub fn magnitude_only_y(threshold: f32) -> Self { + Self { + bounds_y: AxisBounds::magnitude(threshold), + ..Self::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] that restricts values to a minimum value on each axis. + #[inline] + pub const fn at_least(x_min: f32, y_min: f32) -> Self { + Self { + bounds_x: AxisBounds::at_least(x_min), + bounds_y: AxisBounds::at_least(y_min), + } + } + + /// Creates a [`DualAxisBounds`] that restricts values to a minimum value on both axes. + #[inline] + pub const fn at_least_all(min: f32) -> Self { + AxisBounds::at_least(min).extend_dual() + } + + /// Creates a [`DualAxisBounds`] that only restricts X values to a minimum value. + #[inline] + pub const fn at_least_only_x(min: f32) -> Self { + Self { + bounds_x: AxisBounds::at_least(min), + ..Self::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] that only restricts Y values to a minimum value. + #[inline] + pub const fn at_least_only_y(min: f32) -> Self { + Self { + bounds_y: AxisBounds::at_least(min), + ..Self::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] that restricts values to a maximum value on each axis. + #[inline] + pub const fn at_most(x_max: f32, y_max: f32) -> Self { + Self { + bounds_x: AxisBounds::at_most(x_max), + bounds_y: AxisBounds::at_most(y_max), + } + } + + /// Creates a [`DualAxisBounds`] that restricts values to a maximum value on both axes. + #[inline] + pub const fn at_most_all(max: f32) -> Self { + AxisBounds::at_most(max).extend_dual() + } + + /// Creates a [`DualAxisBounds`] that only restricts X values to a maximum value. + #[inline] + pub const fn at_most_only_x(max: f32) -> Self { + Self { + bounds_x: AxisBounds::at_most(max), + ..Self::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] that only restricts Y values to a maximum value. + #[inline] + pub const fn at_most_only_y(max: f32) -> Self { + Self { + bounds_y: AxisBounds::at_most(max), + ..Self::FULL_RANGE + } + } + + /// Returns the bounds for inputs along each axis. + #[inline] + pub fn bounds(&self) -> (AxisBounds, AxisBounds) { + (self.bounds_x, self.bounds_y) + } + + /// Returns the bounds for the X-axis inputs. + #[inline] + pub fn bounds_x(&self) -> AxisBounds { + self.bounds().0 + } + + /// Returns the bounds for the Y-axis inputs. + #[inline] + pub fn bounds_y(&self) -> AxisBounds { + self.bounds().1 + } + + /// Is `input_value` is within the bounds? + #[must_use] + #[inline] + pub fn contains(&self, input_value: Vec2) -> BVec2 { + BVec2::new( + self.bounds_x.contains(input_value.x), + self.bounds_y.contains(input_value.y), + ) + } +} + +impl AxisBounds { + /// Creates a [`DualAxisBounds`] using `self` for both axes. + #[inline] + pub const fn extend_dual(self) -> DualAxisBounds { + DualAxisBounds { + bounds_x: self, + bounds_y: self, + } + } + + /// Creates a [`DualAxisBounds`] only using `self` for the X-axis. + #[inline] + pub const fn extend_dual_only_x(self) -> DualAxisBounds { + DualAxisBounds { + bounds_x: self, + ..DualAxisBounds::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] only using `self` to the Y-axis. + #[inline] + pub const fn extend_dual_only_y(self) -> DualAxisBounds { + DualAxisBounds { + bounds_y: self, + ..DualAxisBounds::FULL_RANGE + } + } + + /// Creates a [`DualAxisBounds`] using `self` to the Y-axis with the given `bounds_x` to the X-axis. + #[inline] + pub const fn extend_dual_with_x(self, bounds_x: Self) -> DualAxisBounds { + DualAxisBounds { + bounds_x, + bounds_y: self, + } + } + + /// Creates a [`DualAxisBounds`] using `self` to the X-axis with the given `bounds_y` to the Y-axis. + #[inline] + pub const fn extend_dual_with_y(self, bounds_y: Self) -> DualAxisBounds { + DualAxisBounds { + bounds_x: self, + bounds_y, + } + } +} + +impl From for DualAxisBounds { + fn from(bounds: AxisBounds) -> Self { + bounds.extend_dual() + } +} + +/// Specifies a cross-shaped region for excluding dual-axis inputs, +/// with min-max independent min-max ranges for each axis, resulting in a per-axis "snapping" effect, +/// helping filter out minor fluctuations to enhance control precision for pure axial motion. +/// +/// In simple terms, this processor is just the dual-axis version of [`AxisExclusion`]. +/// Helpers like [`AxisExclusion::extend_dual()`] and its peers can be used to create a [`DualAxisExclusion`]. +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// // Exclude X within [-0.2, 0.3] and Y within [-0.1, 0.4]. +/// let exclusion = DualAxisExclusion::new((-0.2, 0.3), (-0.1, 0.4)); +/// assert_eq!(exclusion.exclusion_x().min_max(), (-0.2, 0.3)); +/// assert_eq!(exclusion.exclusion_y().min_max(), (-0.1, 0.4)); +/// +/// // Another way to create a DualAxisExclusion. +/// let exclusion_x = AxisExclusion::new(-0.2, 0.3); +/// let exclusion_y = AxisExclusion::new(-0.1, 0.4); +/// assert_eq!(exclusion_x.extend_dual_with_y(exclusion_y), exclusion); +/// +/// for x in -300..300 { +/// let x = x as f32 * 0.01; +/// for y in -300..300 { +/// let y = y as f32 * 0.01; +/// let value = Vec2::new(x, y); +/// +/// assert_eq!(exclusion.process(value).x, exclusion_x.process(x)); +/// assert_eq!(exclusion.process(value).y, exclusion_y.process(y)); +/// } +/// } +/// ``` +#[doc(alias("CrossExclusion", "AxialExclusion"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct DualAxisExclusion { + /// The [`AxisExclusion`] for the X-axis inputs. + pub(crate) exclusion_x: AxisExclusion, + + /// The [`AxisExclusion`] for the Y-axis inputs. + pub(crate) exclusion_y: AxisExclusion, +} + +#[serde_typetag] +impl DualAxisProcessor for DualAxisExclusion { + /// Excludes values within the specified region. + #[must_use] + #[inline] + fn process(&self, input_value: Vec2) -> Vec2 { + Vec2::new( + self.exclusion_x.process(input_value.x), + self.exclusion_y.process(input_value.y), + ) + } +} + +impl Default for DualAxisExclusion { + /// Creates a [`DualAxisExclusion`] that excludes input values within `[-1.0, 1.0]` on both axes. + #[inline] + fn default() -> Self { + AxisExclusion::default().extend_dual() + } +} + +impl DualAxisExclusion { + /// Zero-size [`DualAxisExclusion`], leaving values as is. + pub const ZERO: Self = AxisExclusion::ZERO.extend_dual(); + + /// Creates a [`DualAxisExclusion`] that ignores values within the range `[negative_max, positive_min]` on each axis. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min` on each axis. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn new( + (x_negative_max, x_positive_min): (f32, f32), + (y_negative_max, y_positive_min): (f32, f32), + ) -> Self { + Self { + exclusion_x: AxisExclusion::new(x_negative_max, x_positive_min), + exclusion_y: AxisExclusion::new(y_negative_max, y_positive_min), + } + } + + /// Creates a [`DualAxisExclusion`] that ignores values within the range `[negative_max, positive_min]` on both axis. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn all(negative_max: f32, positive_min: f32) -> Self { + let range = (negative_max, positive_min); + Self::new(range, range) + } + + /// Creates a [`DualAxisExclusion`] that only ignores X values within the range `[negative_max, positive_min]`. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn only_x(negative_max: f32, positive_min: f32) -> Self { + Self { + exclusion_x: AxisExclusion::new(negative_max, positive_min), + ..Self::ZERO + } + } + + /// Creates a [`DualAxisExclusion`] that only ignores Y values within the range `[negative_max, positive_min]`. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn only_y(negative_max: f32, positive_min: f32) -> Self { + Self { + exclusion_y: AxisExclusion::new(negative_max, positive_min), + ..Self::ZERO + } + } + + /// Creates a [`DualAxisExclusion`] that ignores values within the range `[-threshold, threshold]` on each axis. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0` on each axis. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric")] + #[inline] + pub fn magnitude(threshold_x: f32, threshold_y: f32) -> Self { + Self { + exclusion_x: AxisExclusion::magnitude(threshold_x), + exclusion_y: AxisExclusion::magnitude(threshold_y), + } + } + + /// Creates a [`DualAxisExclusion`] that ignores values within the range `[-threshold, threshold]` on both axes. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_all")] + #[inline] + pub fn magnitude_all(threshold: f32) -> Self { + Self::magnitude(threshold, threshold) + } + + /// Creates a [`DualAxisExclusion`] that only ignores X values within the range `[-threshold, threshold]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_only_x")] + #[inline] + pub fn magnitude_only_x(threshold: f32) -> Self { + Self { + exclusion_x: AxisExclusion::magnitude(threshold), + ..Self::ZERO + } + } + + /// Creates a [`DualAxisExclusion`] that only ignores Y values within the range `[-threshold, threshold]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_only_y")] + #[inline] + pub fn magnitude_only_y(threshold: f32) -> Self { + Self { + exclusion_y: AxisExclusion::magnitude(threshold), + ..Self::ZERO + } + } + + /// Returns the exclusion ranges for inputs along each axis. + #[inline] + pub fn exclusions(&self) -> (AxisExclusion, AxisExclusion) { + (self.exclusion_x, self.exclusion_y) + } + + /// Returns the exclusion range for the X-axis inputs. + #[inline] + pub fn exclusion_x(&self) -> AxisExclusion { + self.exclusions().0 + } + + /// Returns the exclusion range for the Y-axis inputs. + #[inline] + pub fn exclusion_y(&self) -> AxisExclusion { + self.exclusions().1 + } + + /// Is the `input_value` within the exclusion range? + #[must_use] + #[inline] + pub fn contains(&self, input_value: Vec2) -> BVec2 { + BVec2::new( + self.exclusion_x.contains(input_value.x), + self.exclusion_y.contains(input_value.y), + ) + } + + /// Creates a [`DualAxisDeadZone`] using `self` as the exclusion range. + pub fn scaled(self) -> DualAxisDeadZone { + self.into() + } +} + +impl AxisExclusion { + /// Creates a [`DualAxisExclusion`] using `self` for both axes. + #[inline] + pub const fn extend_dual(self) -> DualAxisExclusion { + DualAxisExclusion { + exclusion_x: self, + exclusion_y: self, + } + } + + /// Creates a [`DualAxisExclusion`] only using `self` for the X-axis. + #[inline] + pub const fn extend_dual_only_x(self) -> DualAxisExclusion { + DualAxisExclusion { + exclusion_x: self, + ..DualAxisExclusion::ZERO + } + } + + /// Creates a [`DualAxisExclusion`] only using `self` to the Y-axis. + #[inline] + pub const fn extend_dual_only_y(self) -> DualAxisExclusion { + DualAxisExclusion { + exclusion_y: self, + ..DualAxisExclusion::ZERO + } + } + + /// Creates a [`DualAxisExclusion`] using `self` to the Y-axis with the given `bounds_x` to the X-axis. + #[inline] + pub const fn extend_dual_with_x(self, exclusion_x: Self) -> DualAxisExclusion { + DualAxisExclusion { + exclusion_x, + exclusion_y: self, + } + } + + /// Creates a [`DualAxisExclusion`] using `self` to the X-axis with the given `bounds_y` to the Y-axis. + #[inline] + pub const fn extend_dual_with_y(self, exclusion_y: Self) -> DualAxisExclusion { + DualAxisExclusion { + exclusion_x: self, + exclusion_y, + } + } +} + +impl From for DualAxisExclusion { + fn from(exclusion: AxisExclusion) -> Self { + exclusion.extend_dual() + } +} + +/// A scaled version of [`DualAxisExclusion`] with the bounds +/// set to [`DualAxisBounds::magnitude_all(1.0)`](DualAxisBounds::default) +/// that normalizes non-excluded input values into the "live zone", +/// the remaining range within the bounds after dead zone exclusion. +/// +/// Each axis is processed individually, resulting in a per-axis "snapping" effect, +/// which enhances control precision for pure axial motion. +/// +/// It is worth considering that this normalizer increases the magnitude of diagonal values. +/// If that is not your goal, you might want to explore alternative normalizers. +/// +/// In simple terms, this processor is just the dual-axis version of [`AxisDeadZone`]. +/// Helpers like [`AxisDeadZone::extend_dual()`] and its peers can be used to create a [`DualAxisDeadZone`]. +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// // Exclude X within [-0.2, 0.3] and Y within [-0.1, 0.4]. +/// let deadzone = DualAxisDeadZone::new((-0.2, 0.3), (-0.1, 0.4)); +/// assert_eq!(deadzone.deadzone_x().exclusion().min_max(), (-0.2, 0.3)); +/// assert_eq!(deadzone.deadzone_y().exclusion().min_max(), (-0.1, 0.4)); +/// +/// // Another way to create a DualAxisDeadZone. +/// let deadzone_x = AxisDeadZone::new(-0.2, 0.3); +/// let deadzone_y = AxisDeadZone::new(-0.1, 0.4); +/// assert_eq!(deadzone_x.extend_dual_with_y(deadzone_y), deadzone); +/// +/// for x in -300..300 { +/// let x = x as f32 * 0.01; +/// for y in -300..300 { +/// let y = y as f32 * 0.01; +/// let value = Vec2::new(x, y); +/// +/// assert_eq!(deadzone.process(value).x, deadzone_x.process(x)); +/// assert_eq!(deadzone.process(value).y, deadzone_y.process(y)); +/// } +/// } +/// ``` +#[doc(alias("CrossDeadZone", "AxialDeadZone"))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct DualAxisDeadZone { + /// The [`AxisDeadZone`] for the X-axis inputs. + pub(crate) deadzone_x: AxisDeadZone, + + /// The [`AxisDeadZone`] for the Y-axis inputs. + pub(crate) deadzone_y: AxisDeadZone, +} + +#[serde_typetag] +impl DualAxisProcessor for DualAxisDeadZone { + /// Normalizes input values into the live zone. + #[must_use] + #[inline] + fn process(&self, input_value: Vec2) -> Vec2 { + Vec2::new( + self.deadzone_x.process(input_value.x), + self.deadzone_y.process(input_value.y), + ) + } +} + +impl Default for DualAxisDeadZone { + /// Creates a [`DualAxisDeadZone`] that excludes input values within the deadzone `[-0.1, 0.1]` on both axes. + fn default() -> Self { + AxisDeadZone::default().extend_dual() + } +} + +impl DualAxisDeadZone { + /// Zero-size [`DualAxisDeadZone`], only restricting values to the range `[-1.0, 1.0]` on both axes. + pub const ZERO: Self = AxisDeadZone::ZERO.extend_dual(); + + /// Creates a [`DualAxisDeadZone`] that excludes values within the range `[negative_max, positive_min]` on each axis. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min` on each axis. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn new( + (x_negative_max, x_positive_min): (f32, f32), + (y_negative_max, y_positive_min): (f32, f32), + ) -> Self { + Self { + deadzone_x: AxisDeadZone::new(x_negative_max, x_positive_min), + deadzone_y: AxisDeadZone::new(y_negative_max, y_positive_min), + } + } + + /// Creates a [`DualAxisDeadZone`] that excludes values within the range `[negative_max, positive_min]` on both axes. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn all(negative_max: f32, positive_min: f32) -> Self { + let range = (negative_max, positive_min); + Self::new(range, range) + } + + /// Creates a [`DualAxisDeadZone`] that only excludes X values within the range `[negative_max, positive_min]`. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn only_x(negative_max: f32, positive_min: f32) -> Self { + Self { + deadzone_x: AxisDeadZone::new(negative_max, positive_min), + ..Self::ZERO + } + } + + /// Creates a [`DualAxisDeadZone`] that only excludes Y values within the range `[negative_max, positive_min]`. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn only_y(negative_max: f32, positive_min: f32) -> Self { + Self { + deadzone_y: AxisDeadZone::new(negative_max, positive_min), + ..Self::ZERO + } + } + + /// Creates a [`DualAxisDeadZone`] that excludes values within the range `[-threshold, threshold]` on each axis. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0` on each axis. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric")] + #[inline] + pub fn magnitude(threshold_x: f32, threshold_y: f32) -> Self { + Self { + deadzone_x: AxisDeadZone::magnitude(threshold_x), + deadzone_y: AxisDeadZone::magnitude(threshold_y), + } + } + + /// Creates a [`DualAxisDeadZone`] that excludes values within the range `[-threshold, threshold]` on both axes. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_all")] + #[inline] + pub fn magnitude_all(threshold: f32) -> Self { + Self::magnitude(threshold, threshold) + } + + /// Creates a [`DualAxisDeadZone`] that only excludes X values within the range `[-threshold, threshold]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_only_x")] + #[inline] + pub fn magnitude_only_x(threshold: f32) -> Self { + Self { + deadzone_x: AxisDeadZone::magnitude(threshold), + ..Self::ZERO + } + } + + /// Creates a [`DualAxisDeadZone`] that only excludes Y values within the range `[-threshold, threshold]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric_only_y")] + #[inline] + pub fn magnitude_only_y(threshold: f32) -> Self { + Self { + deadzone_y: AxisDeadZone::magnitude(threshold), + ..Self::ZERO + } + } + + /// Returns the dead zones for inputs along each axis. + #[inline] + pub fn deadzones(&self) -> (AxisDeadZone, AxisDeadZone) { + (self.deadzone_x, self.deadzone_y) + } + + /// Returns the dead zone for the X-axis inputs. + #[inline] + pub fn deadzone_x(&self) -> AxisDeadZone { + self.deadzones().0 + } + + /// Returns the dead zone for the Y-axis inputs. + #[inline] + pub fn deadzone_y(&self) -> AxisDeadZone { + self.deadzones().1 + } + + /// Returns the [`DualAxisExclusion`] used by this deadzone. + #[inline] + pub fn exclusion(&self) -> DualAxisExclusion { + DualAxisExclusion { + exclusion_x: self.deadzone_x.exclusion(), + exclusion_y: self.deadzone_y.exclusion(), + } + } + + /// Returns the [`DualAxisBounds`] used by this deadzone. + #[inline] + pub fn bounds(&self) -> DualAxisBounds { + DualAxisBounds::default() + } + + /// Is the given `input_value` within the exclusion ranges? + #[must_use] + #[inline] + pub fn within_exclusion(&self, input_value: Vec2) -> BVec2 { + BVec2::new( + self.deadzone_x.within_exclusion(input_value.x), + self.deadzone_y.within_exclusion(input_value.y), + ) + } + + /// Is the given `input_value` within the bounds? + #[must_use] + #[inline] + pub fn within_bounds(&self, input_value: Vec2) -> BVec2 { + BVec2::new( + self.deadzone_x.within_bounds(input_value.x), + self.deadzone_y.within_bounds(input_value.y), + ) + } + + /// Is the given `input_value` within the lower live zone? + #[must_use] + #[inline] + pub fn within_livezone_lower(&self, input_value: Vec2) -> BVec2 { + BVec2::new( + self.deadzone_x.within_livezone_lower(input_value.x), + self.deadzone_y.within_livezone_lower(input_value.y), + ) + } + + /// Is the given `input_value` within the upper live zone? + #[must_use] + #[inline] + pub fn within_livezone_upper(&self, input_value: Vec2) -> BVec2 { + BVec2::new( + self.deadzone_x.within_livezone_upper(input_value.x), + self.deadzone_y.within_livezone_upper(input_value.y), + ) + } +} + +impl AxisDeadZone { + /// Creates a [`DualAxisDeadZone`] using `self` for both axes. + #[inline] + pub const fn extend_dual(self) -> DualAxisDeadZone { + DualAxisDeadZone { + deadzone_x: self, + deadzone_y: self, + } + } + + /// Creates a [`DualAxisDeadZone`] only using `self` for the X-axis. + #[inline] + pub const fn extend_dual_only_x(self) -> DualAxisDeadZone { + DualAxisDeadZone { + deadzone_x: self, + ..DualAxisDeadZone::ZERO + } + } + + /// Creates a [`DualAxisDeadZone`] only using `self` to the Y-axis. + #[inline] + pub const fn extend_dual_only_y(self) -> DualAxisDeadZone { + DualAxisDeadZone { + deadzone_y: self, + ..DualAxisDeadZone::ZERO + } + } + + /// Creates a [`DualAxisDeadZone`] using `self` to the Y-axis with the given `bounds_x` to the X-axis. + #[inline] + pub const fn extend_dual_with_x(self, deadzone_x: Self) -> DualAxisDeadZone { + DualAxisDeadZone { + deadzone_x, + deadzone_y: self, + } + } + + /// Creates a [`DualAxisDeadZone`] using `self` to the X-axis with the given `bounds_y` to the Y-axis. + #[inline] + pub const fn extend_dual_with_y(self, deadzone_y: Self) -> DualAxisDeadZone { + DualAxisDeadZone { + deadzone_x: self, + deadzone_y, + } + } +} + +impl From for DualAxisDeadZone { + fn from(deadzone: AxisDeadZone) -> Self { + deadzone.extend_dual() + } +} + +impl From for DualAxisDeadZone { + fn from(exclusion: DualAxisExclusion) -> Self { + Self::new( + exclusion.exclusion_x.min_max(), + exclusion.exclusion_y.min_max(), + ) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_dual_axis_value_bounds() { + fn test_bounds( + bounds: DualAxisBounds, + (x_min, x_max): (f32, f32), + (y_min, y_max): (f32, f32), + ) { + assert_eq!(bounds.bounds_x().min_max(), (x_min, x_max)); + assert_eq!(bounds.bounds_y().min_max(), (y_min, y_max)); + + let bounds_x = AxisBounds::new(x_min, x_max); + let bounds_y = AxisBounds::new(y_min, y_max); + assert_eq!(bounds_x.extend_dual_with_y(bounds_y), bounds); + + let (bx, by) = bounds.bounds(); + assert_eq!(bx, bounds_x); + assert_eq!(by, bounds_y); + + for x in -300..300 { + let x = x as f32 * 0.01; + for y in -300..300 { + let y = y as f32 * 0.01; + let value = Vec2::new(x, y); + + let expected = BVec2::new(bounds_x.contains(x), bounds_y.contains(y)); + assert_eq!(bounds.contains(value), expected); + + let expected = Vec2::new(bounds_x.process(x), bounds_y.process(y)); + assert_eq!(bounds.process(value), expected); + } + } + } + + let full_range = (f32::MIN, f32::MAX); + + let bounds = DualAxisBounds::FULL_RANGE; + test_bounds(bounds, full_range, full_range); + + let bounds = DualAxisBounds::default(); + test_bounds(bounds, (-1.0, 1.0), (-1.0, 1.0)); + + let bounds = DualAxisBounds::new((-2.0, 2.5), (-1.0, 1.5)); + test_bounds(bounds, (-2.0, 2.5), (-1.0, 1.5)); + + let bounds = DualAxisBounds::all(-2.0, 2.5); + test_bounds(bounds, (-2.0, 2.5), (-2.0, 2.5)); + + let bounds = DualAxisBounds::only_x(-2.0, 2.5); + test_bounds(bounds, (-2.0, 2.5), full_range); + + let bounds = DualAxisBounds::only_y(-1.0, 1.5); + test_bounds(bounds, full_range, (-1.0, 1.5)); + + let bounds = DualAxisBounds::magnitude(2.0, 2.5); + test_bounds(bounds, (-2.0, 2.0), (-2.5, 2.5)); + + let bounds = DualAxisBounds::magnitude_all(2.5); + test_bounds(bounds, (-2.5, 2.5), (-2.5, 2.5)); + + let bounds = DualAxisBounds::magnitude_only_x(2.5); + test_bounds(bounds, (-2.5, 2.5), full_range); + + let bounds = DualAxisBounds::magnitude_only_y(2.5); + test_bounds(bounds, full_range, (-2.5, 2.5)); + + let bounds = DualAxisBounds::at_least(2.0, 2.5); + test_bounds(bounds, (2.0, f32::MAX), (2.5, f32::MAX)); + + let bounds = DualAxisBounds::at_least_all(2.5); + test_bounds(bounds, (2.5, f32::MAX), (2.5, f32::MAX)); + + let bounds = DualAxisBounds::at_least_only_x(2.5); + test_bounds(bounds, (2.5, f32::MAX), full_range); + + let bounds = DualAxisBounds::at_least_only_y(2.5); + test_bounds(bounds, full_range, (2.5, f32::MAX)); + + let bounds = DualAxisBounds::at_most(2.0, 2.5); + test_bounds(bounds, (f32::MIN, 2.0), (f32::MIN, 2.5)); + + let bounds = DualAxisBounds::at_most_all(2.5); + test_bounds(bounds, (f32::MIN, 2.5), (f32::MIN, 2.5)); + + let bounds = DualAxisBounds::at_most_only_x(2.5); + test_bounds(bounds, (f32::MIN, 2.5), full_range); + + let bounds = DualAxisBounds::at_most_only_y(2.5); + test_bounds(bounds, full_range, (f32::MIN, 2.5)); + + let bounds_x = AxisBounds::new(-2.0, 2.5); + let bounds_y = AxisBounds::new(-1.0, 1.5); + + test_bounds(bounds_x.extend_dual(), (-2.0, 2.5), (-2.0, 2.5)); + test_bounds(bounds_x.into(), (-2.0, 2.5), (-2.0, 2.5)); + + test_bounds(bounds_y.extend_dual(), (-1.0, 1.5), (-1.0, 1.5)); + test_bounds(bounds_y.into(), (-1.0, 1.5), (-1.0, 1.5)); + + test_bounds(bounds_x.extend_dual_only_x(), (-2.0, 2.5), full_range); + + test_bounds(bounds_y.extend_dual_only_y(), full_range, (-1.0, 1.5)); + + test_bounds( + bounds_x.extend_dual_with_y(bounds_y), + (-2.0, 2.5), + (-1.0, 1.5), + ); + + test_bounds( + bounds_y.extend_dual_with_x(bounds_x), + (-2.0, 2.5), + (-1.0, 1.5), + ); + } + + #[test] + fn test_dual_axis_exclusion() { + fn test_exclusion( + exclusion: DualAxisExclusion, + (x_negative_max, x_positive_min): (f32, f32), + (y_negative_max, y_positive_min): (f32, f32), + ) { + assert_eq!( + exclusion.exclusion_x.min_max(), + (x_negative_max, x_positive_min) + ); + assert_eq!( + exclusion.exclusion_y.min_max(), + (y_negative_max, y_positive_min) + ); + + let exclusion_x = AxisExclusion::new(x_negative_max, x_positive_min); + let exclusion_y = AxisExclusion::new(y_negative_max, y_positive_min); + assert_eq!(exclusion_x.extend_dual_with_y(exclusion_y), exclusion); + + let (ex, ey) = exclusion.exclusions(); + assert_eq!(ex, exclusion_x); + assert_eq!(ey, exclusion_y); + + for x in -300..300 { + let x = x as f32 * 0.01; + for y in -300..300 { + let y = y as f32 * 0.01; + let value = Vec2::new(x, y); + + assert_eq!( + exclusion.contains(value), + BVec2::new(exclusion_x.contains(x), exclusion_y.contains(y)) + ); + + assert_eq!( + exclusion.process(value), + Vec2::new(exclusion_x.process(x), exclusion_y.process(y)) + ); + } + } + } + + let zero_size = (0.0, 0.0); + + let exclusion = DualAxisExclusion::ZERO; + test_exclusion(exclusion, zero_size, zero_size); + + let exclusion = DualAxisExclusion::default(); + test_exclusion(exclusion, (-0.1, 0.1), (-0.1, 0.1)); + + let exclusion = DualAxisExclusion::new((-0.2, 0.3), (-0.1, 0.4)); + test_exclusion(exclusion, (-0.2, 0.3), (-0.1, 0.4)); + + let exclusion = DualAxisExclusion::all(-0.2, 0.3); + test_exclusion(exclusion, (-0.2, 0.3), (-0.2, 0.3)); + + let exclusion = DualAxisExclusion::only_x(-0.2, 0.3); + test_exclusion(exclusion, (-0.2, 0.3), zero_size); + + let exclusion = DualAxisExclusion::only_y(-0.1, 0.4); + test_exclusion(exclusion, zero_size, (-0.1, 0.4)); + + let exclusion = DualAxisExclusion::magnitude(0.2, 0.3); + test_exclusion(exclusion, (-0.2, 0.2), (-0.3, 0.3)); + + let exclusion = DualAxisExclusion::magnitude_all(0.3); + test_exclusion(exclusion, (-0.3, 0.3), (-0.3, 0.3)); + + let exclusion = DualAxisExclusion::magnitude_only_x(0.3); + test_exclusion(exclusion, (-0.3, 0.3), zero_size); + + let exclusion = DualAxisExclusion::magnitude_only_y(0.3); + test_exclusion(exclusion, zero_size, (-0.3, 0.3)); + + let exclusion_x = AxisExclusion::new(-0.2, 0.3); + let exclusion_y = AxisExclusion::new(-0.1, 0.4); + + test_exclusion(exclusion_x.extend_dual(), (-0.2, 0.3), (-0.2, 0.3)); + test_exclusion(exclusion_x.into(), (-0.2, 0.3), (-0.2, 0.3)); + test_exclusion(exclusion_y.into(), (-0.1, 0.4), (-0.1, 0.4)); + + test_exclusion(exclusion_x.extend_dual_only_x(), (-0.2, 0.3), zero_size); + + test_exclusion(exclusion_y.extend_dual_only_y(), zero_size, (-0.1, 0.4)); + + test_exclusion( + exclusion_x.extend_dual_with_y(exclusion_y), + (-0.2, 0.3), + (-0.1, 0.4), + ); + + test_exclusion( + exclusion_y.extend_dual_with_x(exclusion_x), + (-0.2, 0.3), + (-0.1, 0.4), + ); + } + + #[test] + fn test_dual_axis_deadzone() { + fn test_deadzone( + deadzone: DualAxisDeadZone, + (x_negative_max, x_positive_min): (f32, f32), + (y_negative_max, y_positive_min): (f32, f32), + ) { + assert_eq!( + deadzone.deadzone_x.exclusion().min_max(), + (x_negative_max, x_positive_min) + ); + assert_eq!( + deadzone.deadzone_y.exclusion().min_max(), + (y_negative_max, y_positive_min) + ); + + let deadzone_x = AxisDeadZone::new(x_negative_max, x_positive_min); + let deadzone_y = AxisDeadZone::new(y_negative_max, y_positive_min); + assert_eq!(deadzone_x.extend_dual_with_y(deadzone_y), deadzone); + + let exclusion = DualAxisExclusion::new( + (x_negative_max, x_positive_min), + (y_negative_max, y_positive_min), + ); + assert_eq!(exclusion.scaled(), deadzone); + + let (dx, dy) = deadzone.deadzones(); + assert_eq!(dx, deadzone_x); + assert_eq!(dy, deadzone_y); + + for x in -300..300 { + let x = x as f32 * 0.01; + for y in -300..300 { + let y = y as f32 * 0.01; + let value = Vec2::new(x, y); + + assert_eq!( + deadzone.within_exclusion(value), + BVec2::new( + deadzone_x.within_exclusion(x), + deadzone_y.within_exclusion(y) + ) + ); + + assert_eq!( + deadzone.within_bounds(value), + BVec2::new(deadzone_x.within_bounds(x), deadzone_y.within_bounds(y)) + ); + + assert_eq!( + deadzone.within_livezone_lower(value), + BVec2::new( + deadzone_x.within_livezone_lower(x), + deadzone_y.within_livezone_lower(y) + ) + ); + + assert_eq!( + deadzone.within_livezone_upper(value), + BVec2::new( + deadzone_x.within_livezone_upper(x), + deadzone_y.within_livezone_upper(y) + ) + ); + + assert_eq!( + deadzone.process(value), + Vec2::new(deadzone_x.process(x), deadzone_y.process(y)) + ); + } + } + } + + let zero_size = (0.0, 0.0); + + let deadzone = DualAxisDeadZone::ZERO; + test_deadzone(deadzone, zero_size, zero_size); + + let deadzone = DualAxisDeadZone::default(); + test_deadzone(deadzone, (-0.1, 0.1), (-0.1, 0.1)); + + let deadzone = DualAxisDeadZone::new((-0.2, 0.3), (-0.1, 0.4)); + test_deadzone(deadzone, (-0.2, 0.3), (-0.1, 0.4)); + + let deadzone = DualAxisDeadZone::all(-0.2, 0.3); + test_deadzone(deadzone, (-0.2, 0.3), (-0.2, 0.3)); + + let deadzone = DualAxisDeadZone::only_x(-0.2, 0.3); + test_deadzone(deadzone, (-0.2, 0.3), zero_size); + + let deadzone = DualAxisDeadZone::only_y(-0.1, 0.4); + test_deadzone(deadzone, zero_size, (-0.1, 0.4)); + + let deadzone = DualAxisDeadZone::magnitude(0.2, 0.3); + test_deadzone(deadzone, (-0.2, 0.2), (-0.3, 0.3)); + + let deadzone = DualAxisDeadZone::magnitude_all(0.3); + test_deadzone(deadzone, (-0.3, 0.3), (-0.3, 0.3)); + + let deadzone = DualAxisDeadZone::magnitude_only_x(0.3); + test_deadzone(deadzone, (-0.3, 0.3), zero_size); + + let deadzone = DualAxisDeadZone::magnitude_only_y(0.3); + test_deadzone(deadzone, zero_size, (-0.3, 0.3)); + } +} diff --git a/src/input_processing/mod.rs b/src/input_processing/mod.rs new file mode 100644 index 00000000..d11b8742 --- /dev/null +++ b/src/input_processing/mod.rs @@ -0,0 +1,84 @@ +//! Processors for input values +//! +//! This module simplifies input handling in your application by providing processors +//! for refining and manipulating values before reaching the application logic. +//! +//! # Processor Traits +//! +//! The foundation of this module lies in these core traits. +//! +//! - [`AxisProcessor`]: Handles `f32` values for single-axis inputs. +//! - [`DualAxisProcessor`]: Handles [`Vec2`](bevy::prelude::Vec2) values for dual-axis inputs. +//! +//! Need something specific? You can also create your own processors by implementing these traits for specific needs. +//! +//! Feel free to suggest additions to the built-in processors if you have a common use case! +//! +//! # Built-in Processors +//! +//! ## Pipelines +//! +//! Pipelines are dynamic sequence containers of processors, +//! transforming input values by passing them through each processor in the pipeline, +//! allowing to create complex processing workflows by combining simpler steps. +//! +//! - [`AxisProcessingPipeline`]: Transforms single-axis input values with a sequence of [`AxisProcessor`]s. +//! - [`DualAxisProcessingPipeline`]: Transforms dual-axis input values with a sequence of [`DualAxisProcessor`]s. +//! +//! While pipelines offer flexibility in dynamic managing processing steps, +//! they may hinder compiler optimizations such as inlining or dead code elimination. +//! For performance-critical scenarios, consider creating you own processors for improved performance. +//! +//! ## Inversion +//! +//! Inversion flips the sign of input values, resulting in a directional reversal of control. +//! For example, positive values become negative, and up becomes down. +//! +//! - [`AxisInverted`]: Single-axis inversion. +//! - [`DualAxisInverted`]: Dual-axis inversion. +//! +//! ## Sensitivity +//! +//! Sensitivity scales input values with a specified multiplier (doubling, halving, etc.), +//! allowing fine-tuning the responsiveness of controls. +//! +//! - [`AxisSensitivity`]: Single-axis scaling. +//! - [`DualAxisSensitivity`]: Dual-axis scaling. +//! +//! ## Value Bounds +//! +//! Value bounds define an acceptable range for input values, +//! clamping out-of-bounds inputs to the nearest valid value and leaving others as is +//! to avoid unexpected behavior caused by extreme inputs. +//! +//! - [`AxisBounds`]: A min-max range for valid single-axis inputs. +//! - [`DualAxisBounds`]: A square-shaped region for valid dual-axis inputs, with independent min-max ranges for each axis. +//! - [`CircleBounds`]: A circular region for valid dual-axis inputs, with a radius defining the maximum magnitude. +//! +//! ## Dead Zones +//! +//! ### Unscaled Versions +//! +//! Unscaled dead zones specify regions where input values within the regions +//! are considered excluded from further processing and treated as zeros, +//! helping filter out minor fluctuations and unintended movements. +//! +//! - [`AxisExclusion`]: A min-max range for excluding single-axis input values. +//! - [`DualAxisExclusion`]: A cross-shaped region for excluding dual-axis inputs, with independent min-max ranges for each axis. +//! - [`CircleExclusion`]: A circular region for excluding dual-axis inputs, with a radius defining the maximum excluded magnitude. +//! +//! ### Scaled Versions +//! +//! Scaled dead zones transform input values by restricting values within the default bounds, +//! and then scaling non-excluded values linearly into the "live zone", +//! the remaining region within the bounds after dead zone exclusion. +//! +//! - [`AxisDeadZone`]: A scaled version of [`AxisExclusion`] with the bounds set to [`AxisBounds::magnitude(1.0)`](AxisBounds::default). +//! - [`DualAxisDeadZone`]: A scaled version of [`DualAxisExclusion`] with the bounds set to [`DualAxisBounds::magnitude_all(1.0)`](DualAxisBounds::default). +//! - [`CircleDeadZone`]: A scaled version of [`CircleExclusion`] with the bounds set to [`CircleBounds::new(1.0)`](CircleBounds::default). + +pub use self::dual_axis::*; +pub use self::single_axis::*; + +pub mod dual_axis; +pub mod single_axis; diff --git a/src/input_processing/single_axis/mod.rs b/src/input_processing/single_axis/mod.rs new file mode 100644 index 00000000..22eccb38 --- /dev/null +++ b/src/input_processing/single_axis/mod.rs @@ -0,0 +1,333 @@ +//! Processors for single-axis input values + +use std::any::{Any, TypeId}; +use std::fmt::{Debug, Formatter}; +use std::hash::{Hash, Hasher}; +use std::sync::RwLock; + +use bevy::prelude::App; +use bevy::reflect::utility::{reflect_hasher, GenericTypePathCell, NonGenericTypeInfoCell}; +use bevy::reflect::{ + erased_serde, FromReflect, FromType, GetTypeRegistration, Reflect, ReflectDeserialize, + ReflectFromPtr, ReflectKind, ReflectMut, ReflectOwned, ReflectRef, ReflectSerialize, TypeInfo, + TypePath, TypeRegistration, Typed, ValueInfo, +}; +use dyn_clone::DynClone; +use dyn_eq::DynEq; +use dyn_hash::DynHash; +use once_cell::sync::Lazy; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use serde_flexitos::ser::require_erased_serialize_impl; +use serde_flexitos::{serialize_trait_object, MapRegistry, Registry}; + +use crate::typetag::RegisterTypeTag; + +pub use self::modifier::*; +pub use self::pipeline::*; +pub use self::range::*; + +mod modifier; +mod pipeline; +mod range; + +/// A trait for processing single-axis input values, +/// accepting a `f32` input and producing a `f32` output. +/// +/// # Examples +/// +/// ```rust +/// use std::hash::{Hash, Hasher}; +/// use bevy::prelude::*; +/// use bevy::utils::FloatOrd; +/// use serde::{Deserialize, Serialize}; +/// use leafwing_input_manager::prelude::*; +/// +/// /// Doubles the input, takes the absolute value, +/// /// and discards results that meet the specified condition. +/// // If your processor includes fields not implemented Eq and Hash, +/// // implementation is necessary as shown below. +/// // Otherwise, you can derive Eq and Hash directly. +/// #[derive(Debug, Clone, PartialEq, Reflect, Serialize, Deserialize)] +/// pub struct DoubleAbsoluteValueThenIgnored(pub f32); +/// +/// // Add this attribute for ensuring proper serialization and deserialization. +/// #[serde_typetag] +/// impl AxisProcessor for DoubleAbsoluteValueThenIgnored { +/// fn process(&self, input_value: f32) -> f32 { +/// // Implement the logic just like you would in a normal function. +/// +/// // You can use other processors within this function. +/// let value = AxisSensitivity(2.0).process(input_value); +/// +/// let value = value.abs(); +/// if value == self.0 { +/// 0.0 +/// } else { +/// value +/// } +/// } +/// } +/// +/// // Unfortunately, manual implementation is required due to the float field. +/// impl Eq for DoubleAbsoluteValueThenIgnored {} +/// impl Hash for DoubleAbsoluteValueThenIgnored { +/// fn hash(&self, state: &mut H) { +/// // Encapsulate the float field for hashing. +/// FloatOrd(self.0).hash(state); +/// } +/// } +/// +/// // Remember to register your processor - it will ensure everything works smoothly! +/// let mut app = App::new(); +/// app.register_axis_processor::(); +/// +/// // Now you can use it! +/// let processor = DoubleAbsoluteValueThenIgnored(4.0); +/// +/// // Rejected! +/// assert_eq!(processor.process(2.0), 0.0); +/// assert_eq!(processor.process(-2.0), 0.0); +/// +/// // Others are just doubled absolute value. +/// assert_eq!(processor.process(6.0), 12.0); +/// assert_eq!(processor.process(4.0), 8.0); +/// assert_eq!(processor.process(0.0), 0.0); +/// assert_eq!(processor.process(-4.0), 8.0); +/// assert_eq!(processor.process(-6.0), 12.0); +/// ``` +pub trait AxisProcessor: + Send + Sync + Debug + DynClone + DynEq + DynHash + Reflect + erased_serde::Serialize +{ + /// Computes the result by processing the `input_value`. + fn process(&self, input_value: f32) -> f32; +} + +dyn_clone::clone_trait_object!(AxisProcessor); +dyn_eq::eq_trait_object!(AxisProcessor); +dyn_hash::hash_trait_object!(AxisProcessor); + +impl Reflect for Box { + fn get_represented_type_info(&self) -> Option<&'static TypeInfo> { + Some(Self::type_info()) + } + + fn into_any(self: Box) -> Box { + self + } + + fn as_any(&self) -> &dyn Any { + self + } + + fn as_any_mut(&mut self) -> &mut dyn Any { + self + } + + fn into_reflect(self: Box) -> Box { + self + } + + fn as_reflect(&self) -> &dyn Reflect { + self + } + + fn as_reflect_mut(&mut self) -> &mut dyn Reflect { + self + } + + fn apply(&mut self, value: &dyn Reflect) { + let value = value.as_any(); + if let Some(value) = value.downcast_ref::() { + *self = value.clone(); + } else { + panic!( + "Value is not a std::boxed::Box.", + module_path!(), + ); + } + } + + fn set(&mut self, value: Box) -> Result<(), Box> { + *self = value.take()?; + Ok(()) + } + + fn reflect_kind(&self) -> ReflectKind { + ReflectKind::Value + } + + fn reflect_ref(&self) -> ReflectRef { + ReflectRef::Value(self) + } + + fn reflect_mut(&mut self) -> ReflectMut { + ReflectMut::Value(self) + } + + fn reflect_owned(self: Box) -> ReflectOwned { + ReflectOwned::Value(self) + } + + fn clone_value(&self) -> Box { + Box::new(self.clone()) + } + + fn reflect_hash(&self) -> Option { + let mut hasher = reflect_hasher(); + let type_id = TypeId::of::>(); + Hash::hash(&type_id, &mut hasher); + Hash::hash(self, &mut hasher); + Some(hasher.finish()) + } + + fn reflect_partial_eq(&self, value: &dyn Reflect) -> Option { + let value = value.as_any(); + value + .downcast_ref::() + .map(|value| self.dyn_eq(value)) + .or(Some(false)) + } + + fn debug(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + Debug::fmt(self, f) + } +} + +impl Typed for Box { + fn type_info() -> &'static TypeInfo { + static CELL: NonGenericTypeInfoCell = NonGenericTypeInfoCell::new(); + CELL.get_or_set(|| TypeInfo::Value(ValueInfo::new::())) + } +} + +impl TypePath for Box { + fn type_path() -> &'static str { + static CELL: GenericTypePathCell = GenericTypePathCell::new(); + CELL.get_or_insert::(|| { + { + format!("std::boxed::Box(dyn {}::AxisProcessor)", module_path!()) + } + }) + } + + fn short_type_path() -> &'static str { + static CELL: GenericTypePathCell = GenericTypePathCell::new(); + CELL.get_or_insert::(|| "Box(dyn AxisProcessor)".to_string()) + } + + fn type_ident() -> Option<&'static str> { + Some("AxisProcessor") + } + + fn crate_name() -> Option<&'static str> { + Some(module_path!().split(':').next().unwrap()) + } + + fn module_path() -> Option<&'static str> { + Some(module_path!()) + } +} + +impl GetTypeRegistration for Box { + fn get_type_registration() -> TypeRegistration { + let mut registration = TypeRegistration::of::(); + registration.insert::(FromType::::from_type()); + registration.insert::(FromType::::from_type()); + registration.insert::(FromType::::from_type()); + registration + } +} + +impl FromReflect for Box { + fn from_reflect(reflect: &dyn Reflect) -> Option { + Some(reflect.as_any().downcast_ref::()?.clone()) + } +} + +impl<'a> Serialize for dyn AxisProcessor + 'a { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + // Check that `AxisProcessor` has `erased_serde::Serialize` as a super trait, preventing infinite recursion at runtime. + const fn __check_erased_serialize_super_trait() { + require_erased_serialize_impl::(); + } + serialize_trait_object(serializer, self.reflect_short_type_path(), self) + } +} + +impl<'de> Deserialize<'de> for Box { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let registry = unsafe { PROCESSOR_REGISTRY.read().unwrap() }; + registry.deserialize_trait_object(deserializer) + } +} + +/// Registry of deserializers for [`AxisProcessor`]s. +static mut PROCESSOR_REGISTRY: Lazy>> = + Lazy::new(|| RwLock::new(MapRegistry::new("AxisProcessor"))); + +/// A trait for registering a specific [`AxisProcessor`]. +pub trait RegisterAxisProcessor { + /// Registers the specified [`AxisProcessor`]. + fn register_axis_processor<'de, T>(&mut self) -> &mut Self + where + T: RegisterTypeTag<'de, dyn AxisProcessor> + GetTypeRegistration; +} + +impl RegisterAxisProcessor for App { + #[allow(unsafe_code)] + fn register_axis_processor<'de, T>(&mut self) -> &mut Self + where + T: RegisterTypeTag<'de, dyn AxisProcessor> + GetTypeRegistration, + { + let mut registry = unsafe { PROCESSOR_REGISTRY.write().unwrap() }; + T::register_typetag(&mut registry); + self.register_type::(); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_test::{assert_tokens, Token}; + + #[test] + fn test_serde_dual_axis_processor() { + let mut app = App::new(); + app.register_axis_processor::(); + app.register_axis_processor::(); + + let inversion: Box = Box::new(AxisInverted); + assert_tokens( + &inversion, + &[ + Token::Map { len: Some(1) }, + Token::BorrowedStr("AxisInverted"), + Token::UnitStruct { + name: "AxisInverted", + }, + Token::MapEnd, + ], + ); + + let sensitivity: Box = Box::new(AxisSensitivity(5.0)); + assert_tokens( + &sensitivity, + &[ + Token::Map { len: Some(1) }, + Token::BorrowedStr("AxisSensitivity"), + Token::NewtypeStruct { + name: "AxisSensitivity", + }, + Token::F32(5.0), + Token::MapEnd, + ], + ); + } +} diff --git a/src/input_processing/single_axis/modifier.rs b/src/input_processing/single_axis/modifier.rs new file mode 100644 index 00000000..5b57786f --- /dev/null +++ b/src/input_processing/single_axis/modifier.rs @@ -0,0 +1,99 @@ +//! Modifiers for single-axis inputs + +use std::hash::{Hash, Hasher}; + +use bevy::prelude::Reflect; +use bevy::utils::FloatOrd; +use leafwing_input_manager_macros::serde_typetag; +use serde::{Deserialize, Serialize}; + +use crate as leafwing_input_manager; +use crate::input_processing::AxisProcessor; + +/// Flips the sign of single-axis input values, resulting in a directional reversal of control. +/// +/// ```rust +/// use leafwing_input_manager::prelude::*; +/// +/// assert_eq!(AxisInverted.process(2.5), -2.5); +/// assert_eq!(AxisInverted.process(-2.5), 2.5); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct AxisInverted; + +#[serde_typetag] +impl AxisProcessor for AxisInverted { + /// Returns the opposite value of the `input_value`. + #[must_use] + #[inline] + fn process(&self, input_value: f32) -> f32 { + -input_value + } +} + +/// Scales input values on an axis using a specified multiplier, +/// allowing fine-tuning the responsiveness of controls. +/// +/// ```rust +/// use leafwing_input_manager::prelude::*; +/// +/// // Doubled! +/// assert_eq!(AxisSensitivity(2.0).process(2.0), 4.0); +/// +/// // Halved! +/// assert_eq!(AxisSensitivity(0.5).process(2.0), 1.0); +/// +/// // Negated and halved! +/// assert_eq!(AxisSensitivity(-0.5).process(2.0), -1.0); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct AxisSensitivity(pub f32); + +#[serde_typetag] +impl AxisProcessor for AxisSensitivity { + /// Multiples the `input_value` by the specified sensitivity factor. + #[must_use] + #[inline] + fn process(&self, input_value: f32) -> f32 { + self.0 * input_value + } +} + +impl Eq for AxisSensitivity {} + +impl Hash for AxisSensitivity { + fn hash(&self, state: &mut H) { + FloatOrd(self.0).hash(state); + } +} + +#[cfg(test)] +mod tests { + use crate::input_processing::single_axis::*; + + #[test] + fn test_axis_inverted() { + for value in -300..300 { + let value = value as f32 * 0.01; + + assert_eq!(AxisInverted.process(value), -value); + assert_eq!(AxisInverted.process(-value), value); + } + } + + #[test] + fn test_axis_sensitivity() { + for value in -300..300 { + let value = value as f32 * 0.01; + + for sensitivity in -300..300 { + let sensitivity = sensitivity as f32 * 0.01; + + let scale = AxisSensitivity(sensitivity); + assert_eq!(scale.process(value), sensitivity * value); + } + } + } +} diff --git a/src/input_processing/single_axis/pipeline.rs b/src/input_processing/single_axis/pipeline.rs new file mode 100644 index 00000000..2ecbde83 --- /dev/null +++ b/src/input_processing/single_axis/pipeline.rs @@ -0,0 +1,160 @@ +//! Processing pipeline for single-axis inputs. + +use bevy::prelude::Reflect; +use leafwing_input_manager_macros::serde_typetag; +use serde::{Deserialize, Serialize}; + +use crate as leafwing_input_manager; +use crate::input_processing::AxisProcessor; + +/// A dynamic sequence container of [`AxisProcessor`]s designed for processing input values. +/// +/// # Warning +/// +/// This flexibility may hinder compiler optimizations such as inlining or dead code elimination. +/// For performance-critical scenarios, consider creating your own processors for improved performance. +/// +/// # Examples +/// +/// ```rust +/// use leafwing_input_manager::prelude::*; +/// +/// // Just a heads up, the default pipeline won't tweak values. +/// let pipeline = AxisProcessingPipeline::default(); +/// assert_eq!(pipeline.process(1.5), 1.5); +/// +/// // You can link up a sequence of processors to make a pipeline. +/// let mut pipeline = AxisProcessingPipeline::default() +/// .with(AxisSensitivity(2.0)) +/// .with(AxisInverted); +/// +/// // Now it doubles and flips values. +/// assert_eq!(pipeline.process(1.5), -3.0); +/// +/// // You can also add a processor just like you would do with a Vec. +/// pipeline.push(AxisSensitivity(1.5)); +/// +/// // Now it triples and inverts values. +/// assert_eq!(pipeline.process(1.5), -4.5); +/// +/// // Plus, you can switch out a processor at a specific index. +/// pipeline.set(2, AxisSensitivity(-2.0)); +/// +/// // Now it multiplies values by -4 and inverts the result. +/// assert_eq!(pipeline.process(1.5), 6.0); +/// +/// // If needed, you can remove all processors. +/// pipeline.clear(); +/// +/// // Now it just leaves values as is. +/// assert_eq!(pipeline.process(1.5), 1.5); +/// ``` +#[must_use] +#[derive(Debug, Default, Clone, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] +pub struct AxisProcessingPipeline(pub(crate) Vec>); + +#[serde_typetag] +impl AxisProcessor for AxisProcessingPipeline { + /// Computes the result by passing the `input_value` through this pipeline. + #[must_use] + #[inline] + fn process(&self, input_value: f32) -> f32 { + self.0 + .iter() + .fold(input_value, |value, next| next.process(value)) + } +} + +impl AxisProcessingPipeline { + /// Appends the given [`AxisProcessor`] into this pipeline and returns `self`. + #[inline] + pub fn with(mut self, processor: impl AxisProcessor) -> Self { + self.push(processor); + self + } + + /// Appends the given [`AxisProcessor`] into this pipeline. + #[inline] + pub fn push(&mut self, processor: impl AxisProcessor) { + self.0.push(Box::new(processor)); + } + + /// Replaces the processor at the `index` with the given [`AxisProcessor`]. + #[inline] + pub fn set(&mut self, index: usize, processor: impl AxisProcessor) { + self.0[index] = Box::new(processor); + } + + /// Removes all processors in this pipeline. + #[inline] + pub fn clear(&mut self) { + self.0.clear(); + } +} + +/// A trait for appending an [`AxisProcessor`] as the next processing step in the pipeline, +/// enabling further processing of input values. +pub trait WithAxisProcessor { + /// Appends an [`AxisProcessor`] as the next processing step in the pipeline. + fn with_processor(self, processor: impl AxisProcessor) -> Self; +} + +impl WithAxisProcessor for Box { + /// Creates a new boxed [`AxisProcessingPipeline`] with the existing steps + /// and appends the given [`AxisProcessor`]. + fn with_processor(self, processor: impl AxisProcessor) -> Self { + let pipeline = match Reflect::as_any(&*self).downcast_ref::() { + Some(pipeline) => pipeline.clone(), + None => AxisProcessingPipeline(vec![self]), + }; + Box::new(pipeline.with(processor)) + } +} + +#[cfg(test)] +mod tests { + use crate::input_processing::*; + + #[test] + fn test_axis_processing_pipeline() { + // Chain processors to make a new pipeline. + let mut pipeline = AxisProcessingPipeline::default() + .with(AxisSensitivity(4.0)) + .with(AxisInverted) + .with(AxisSensitivity(4.0)); + + pipeline.push(AxisInverted); + + pipeline.set(2, AxisSensitivity(6.0)); + + // This pipeline now scales input values by a factor of 24.0 + assert_eq!(pipeline.process(2.0), 48.0); + assert_eq!(pipeline.process(-3.0), -72.0); + + // Now it just leaves values as is. + pipeline.clear(); + assert_eq!(pipeline, AxisProcessingPipeline::default()); + assert_eq!(pipeline.process(4.0), 4.0); + } + + #[test] + fn test_with_axis_processor() { + let first = AxisSensitivity(2.0); + let first_boxed: Box = Box::new(first); + + let second = AxisSensitivity(3.0); + let merged_second = first_boxed.with_processor(second); + let expected = AxisProcessingPipeline::default().with(first).with(second); + let expected_boxed: Box = Box::new(expected); + assert_eq!(merged_second, expected_boxed); + + let third = AxisSensitivity(4.0); + let merged_third = merged_second.with_processor(third); + let expected = AxisProcessingPipeline::default() + .with(first) + .with(second) + .with(third); + let expected_boxed: Box = Box::new(expected); + assert_eq!(merged_third, expected_boxed); + } +} diff --git a/src/input_processing/single_axis/range.rs b/src/input_processing/single_axis/range.rs new file mode 100644 index 00000000..537bf965 --- /dev/null +++ b/src/input_processing/single_axis/range.rs @@ -0,0 +1,652 @@ +//! Range processors for single-axis inputs + +use std::hash::{Hash, Hasher}; + +use bevy::prelude::Reflect; +use bevy::utils::FloatOrd; +use leafwing_input_manager_macros::serde_typetag; +use serde::{Deserialize, Serialize}; + +use super::AxisProcessor; +use crate as leafwing_input_manager; + +/// Specifies an acceptable min-max range for valid single-axis inputs, +/// restricting all value stays within intended limits +/// to avoid unexpected behavior caused by extreme inputs. +/// +/// ```rust +/// use leafwing_input_manager::prelude::*; +/// +/// // Restrict values to [-2.0, 1.5]. +/// let bounds = AxisBounds::new(-2.0, 1.5); +/// +/// for value in -300..300 { +/// let value = value as f32 * 0.01; +/// assert_eq!(bounds.process(value), value.clamp(-2.0, 1.5)); +/// } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct AxisBounds { + /// The minimum value of valid inputs. + pub(crate) min: f32, + + /// The maximum value of valid inputs. + pub(crate) max: f32, +} + +#[serde_typetag] +impl AxisProcessor for AxisBounds { + /// Clamps `input_value` within the bounds. + #[must_use] + #[inline(always)] + fn process(&self, input_value: f32) -> f32 { + // clamp() includes checks if either bound is set to NaN, + // but the constructors guarantee that all bounds will not be NaN. + input_value.min(self.max).max(self.min) + } +} + +impl Default for AxisBounds { + /// Creates an [`AxisBounds`] that restricts values to the range `[-1.0, 1.0]`. + #[inline] + fn default() -> Self { + Self { + min: -1.0, + max: 1.0, + } + } +} + +impl AxisBounds { + /// Unlimited [`AxisBounds`]. + pub const FULL_RANGE: Self = Self { + min: f32::MIN, + max: f32::MAX, + }; + + /// Creates an [`AxisBounds`] that restricts values to the given range `[min, max]`. + /// + /// # Requirements + /// + /// - `min` <= `max`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn new(min: f32, max: f32) -> Self { + // PartialOrd for f32 ensures that NaN values are checked during comparisons. + assert!(min <= max); + Self { min, max } + } + + /// Creates an [`AxisBounds`] that restricts values within the range `[-threshold, threshold]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric")] + #[inline] + pub fn magnitude(threshold: f32) -> Self { + Self::new(-threshold, threshold) + } + + /// Creates an [`AxisBounds`] that restricts values to a minimum value. + #[inline] + pub const fn at_least(min: f32) -> Self { + Self { + min, + ..Self::FULL_RANGE + } + } + + /// Creates an [`AxisBounds`] that restricts values to a maximum value. + #[inline] + pub const fn at_most(max: f32) -> Self { + Self { + max, + ..Self::FULL_RANGE + } + } + + /// Returns the minimum and maximum bounds. + #[must_use] + #[inline] + pub fn min_max(&self) -> (f32, f32) { + (self.min(), self.max()) + } + + /// Returns the minimum bound. + #[must_use] + #[inline] + pub fn min(&self) -> f32 { + self.min + } + + /// Returns the maximum bound. + #[must_use] + #[inline] + pub fn max(&self) -> f32 { + self.max + } + + /// Is the given `input_value` within the bounds? + #[must_use] + #[inline] + pub fn contains(&self, input_value: f32) -> bool { + self.min <= input_value && input_value <= self.max + } +} + +impl Eq for AxisBounds {} + +impl Hash for AxisBounds { + fn hash(&self, state: &mut H) { + FloatOrd(self.min).hash(state); + FloatOrd(self.max).hash(state); + } +} + +/// Specifies an exclusion range for excluding single-axis inputs, +/// helping filter out minor fluctuations and unintended movements. +/// +/// In simple terms, this processor behaves like an [`AxisDeadZone`] without normalization. +/// +/// # Examples +/// +/// ```rust +/// use leafwing_input_manager::prelude::*; +/// +/// // Exclude values between -0.2 and 0.3 +/// let exclusion = AxisExclusion::new(-0.2, 0.3); +/// +/// for value in -300..300 { +/// let value = value as f32 * 0.01; +/// +/// if -0.2 <= value && value <= 0.3 { +/// assert!(exclusion.contains(value)); +/// assert_eq!(exclusion.process(value), 0.0); +/// } else { +/// assert!(!exclusion.contains(value)); +/// assert_eq!(exclusion.process(value), value); +/// } +/// } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct AxisExclusion { + /// The maximum negative value treated as zero. + pub(crate) negative_max: f32, + + /// The minimum positive value treated as zero. + pub(crate) positive_min: f32, +} + +#[serde_typetag] +impl AxisProcessor for AxisExclusion { + /// Excludes values within the specified range. + #[must_use] + #[inline(always)] + fn process(&self, input_value: f32) -> f32 { + if self.contains(input_value) { + 0.0 + } else { + input_value + } + } +} + +impl Default for AxisExclusion { + /// Creates an [`AxisExclusion`] that ignores values within the range `[-0.1, 0.1]`. + #[inline] + fn default() -> Self { + Self { + negative_max: -0.1, + positive_min: 0.1, + } + } +} + +impl AxisExclusion { + /// Zero-size [`AxisExclusion`], leaving values as is. + pub const ZERO: Self = Self { + negative_max: 0.0, + positive_min: 0.0, + }; + + /// Creates an [`AxisExclusion`] that ignores values within the range `[negative_max, positive_min]`. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn new(negative_max: f32, positive_min: f32) -> Self { + assert!(negative_max <= 0.0); + assert!(positive_min >= 0.0); + Self { + negative_max, + positive_min, + } + } + + /// Creates an [`AxisExclusion`] that ignores values within the range `[-threshold, threshold]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric")] + #[inline] + pub fn magnitude(threshold: f32) -> Self { + Self::new(-threshold, threshold) + } + + /// Returns the minimum and maximum bounds. + #[must_use] + #[inline] + pub fn min_max(&self) -> (f32, f32) { + (self.negative_max, self.positive_min) + } + + /// Returns the minimum bound. + #[must_use] + #[inline] + pub fn min(&self) -> f32 { + self.negative_max + } + + /// Returns the maximum bounds. + #[must_use] + #[inline] + pub fn max(&self) -> f32 { + self.positive_min + } + + /// Is `input_value` within the deadzone? + #[must_use] + #[inline] + pub fn contains(&self, input_value: f32) -> bool { + self.negative_max <= input_value && input_value <= self.positive_min + } + + /// Creates an [`AxisDeadZone`] using `self` as the exclusion range. + #[inline] + pub fn scaled(self) -> AxisDeadZone { + self.into() + } +} + +impl Eq for AxisExclusion {} + +impl Hash for AxisExclusion { + fn hash(&self, state: &mut H) { + FloatOrd(self.negative_max).hash(state); + FloatOrd(self.positive_min).hash(state); + } +} + +/// A scaled version of [`AxisExclusion`] with the bounds +/// set to [`AxisBounds::magnitude(1.0)`](AxisBounds::default) +/// that normalizes non-excluded input values into the "live zone", +/// the remaining range within the bounds after dead zone exclusion. +/// +/// # Examples +/// +/// ```rust +/// use bevy::prelude::*; +/// use leafwing_input_manager::prelude::*; +/// +/// // Exclude values between -0.2 and 0.3 +/// let deadzone = AxisDeadZone::new(-0.2, 0.3); +/// +/// // Another way to create an AxisDeadzone. +/// let exclusion = AxisExclusion::new(-0.2, 0.3); +/// assert_eq!(exclusion.scaled(), deadzone); +/// +/// // The bounds after normalization. +/// let bounds = deadzone.bounds(); +/// assert_eq!(bounds.min(), -1.0); +/// assert_eq!(bounds.max(), 1.0); +/// +/// for value in -300..300 { +/// let value = value as f32 * 0.01; +/// +/// // Values within the dead zone are treated as zero. +/// if -0.2 <= value && value <= 0.3 { +/// assert!(deadzone.within_exclusion(value)); +/// assert_eq!(deadzone.process(value), 0.0); +/// } +/// +/// // Values within the live zone are scaled linearly. +/// else if -1.0 <= value && value < -0.2 { +/// assert!(deadzone.within_livezone_lower(value)); +/// +/// let expected = f32::inverse_lerp(-1.0, -0.2, value) - 1.0; +/// assert!((deadzone.process(value) - expected).abs() <= f32::EPSILON); +/// } else if 0.3 < value && value <= 1.0 { +/// assert!(deadzone.within_livezone_upper(value)); +/// +/// let expected = f32::inverse_lerp(0.3, 1.0, value); +/// assert!((deadzone.process(value) - expected).abs() <= f32::EPSILON); +/// } +/// +/// // Values outside the bounds are restricted to the range. +/// else { +/// assert!(!deadzone.within_bounds(value)); +/// assert_eq!(deadzone.process(value), value.clamp(-1.0, 1.0)); +/// } +/// } +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Reflect, Serialize, Deserialize)] +#[must_use] +pub struct AxisDeadZone { + /// The [`AxisExclusion`] used for normalization. + pub(crate) exclusion: AxisExclusion, + + /// Pre-calculated reciprocal of the lower live zone size, + /// preventing division during normalization. + pub(crate) livezone_lower_recip: f32, + + /// Pre-calculated reciprocal of the upper live zone size, + /// preventing division during normalization. + pub(crate) livezone_upper_recip: f32, +} + +#[serde_typetag] +impl AxisProcessor for AxisDeadZone { + /// Normalizes input values into the live zone. + #[must_use] + fn process(&self, input_value: f32) -> f32 { + // Clamp out-of-bounds values to [-1, 1], + // and then exclude values within the dead zone, + // and finally linearly scale the result to the live zone. + if input_value <= 0.0 { + let (bound, deadzone) = self.livezone_lower_min_max(); + let clamped_input = input_value.max(bound); + let distance_to_deadzone = (clamped_input - deadzone).min(0.0); + distance_to_deadzone * self.livezone_lower_recip + } else { + let (deadzone, bound) = self.livezone_upper_min_max(); + let clamped_input = input_value.min(bound); + let distance_to_deadzone = (clamped_input - deadzone).max(0.0); + distance_to_deadzone * self.livezone_upper_recip + } + } +} + +impl From for AxisDeadZone { + fn from(deadzone: AxisExclusion) -> Self { + let (deadzone_min, deadzone_max) = deadzone.min_max(); + let (bound_min, bound_max) = AxisBounds::default().min_max(); + Self { + exclusion: deadzone, + livezone_lower_recip: (deadzone_min - bound_min).recip(), + livezone_upper_recip: (bound_max - deadzone_max).recip(), + } + } +} + +impl Default for AxisDeadZone { + /// Creates an [`AxisDeadZone`] that excludes input values within the deadzone `[-0.1, 0.1]`. + #[inline] + fn default() -> Self { + AxisExclusion::default().into() + } +} + +impl AxisDeadZone { + /// Zero-size [`AxisDeadZone`], only restricting values to the range `[-1.0, 1.0]`. + pub const ZERO: Self = Self { + exclusion: AxisExclusion::ZERO, + livezone_lower_recip: 1.0, + livezone_upper_recip: 1.0, + }; + + /// Creates an [`AxisDeadZone`] that excludes input values + /// within the given deadzone `[negative_max, positive_min]`. + /// + /// # Requirements + /// + /// - `negative_max` <= `0.0` <= `positive_min`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[inline] + pub fn new(negative_max: f32, positive_min: f32) -> Self { + AxisExclusion::new(negative_max, positive_min).into() + } + + /// Creates an [`AxisDeadZone`] that excludes input values below a `threshold` magnitude + /// and then normalizes non-excluded input values into the valid range `[-1.0, 1.0]`. + /// + /// # Requirements + /// + /// - `threshold` >= `0.0`. + /// + /// # Panics + /// + /// Panics if the requirements aren't met. + #[doc(alias = "symmetric")] + #[inline] + pub fn magnitude(threshold: f32) -> Self { + AxisExclusion::magnitude(threshold).into() + } + + /// Returns the [`AxisExclusion`] used by this deadzone. + #[inline] + pub fn exclusion(&self) -> AxisExclusion { + self.exclusion + } + + /// Returns the [`AxisBounds`] used by this deadzone. + #[inline] + pub fn bounds(&self) -> AxisBounds { + AxisBounds::default() + } + + /// Returns the minimum and maximum bounds of the lower live zone used for normalization. + /// + /// In simple terms, this returns `(bounds.min, exclusion.min)`. + #[must_use] + #[inline] + pub fn livezone_lower_min_max(&self) -> (f32, f32) { + (self.bounds().min(), self.exclusion.min()) + } + + /// Returns the minimum and maximum bounds of the upper live zone used for normalization. + /// + /// In simple terms, this returns `(exclusion.max, bounds.max)`. + #[must_use] + #[inline] + pub fn livezone_upper_min_max(&self) -> (f32, f32) { + (self.exclusion.max(), self.bounds().max()) + } + + /// Is the given `input_value` within the exclusion range? + #[must_use] + #[inline] + pub fn within_exclusion(&self, input_value: f32) -> bool { + self.exclusion.contains(input_value) + } + + /// Is the given `input_value` within the bounds? + #[must_use] + #[inline] + pub fn within_bounds(&self, input_value: f32) -> bool { + self.bounds().contains(input_value) + } + + /// Is the given `input_value` within the lower live zone? + #[must_use] + #[inline] + pub fn within_livezone_lower(&self, input_value: f32) -> bool { + let (min, max) = self.livezone_lower_min_max(); + min <= input_value && input_value <= max + } + + /// Is the given `input_value` within the upper live zone? + #[must_use] + #[inline] + pub fn within_livezone_upper(&self, input_value: f32) -> bool { + let (min, max) = self.livezone_upper_min_max(); + min <= input_value && input_value <= max + } +} + +impl Eq for AxisDeadZone {} + +impl Hash for AxisDeadZone { + fn hash(&self, state: &mut H) { + self.exclusion.hash(state); + } +} + +#[cfg(test)] +mod tests { + use crate::input_processing::*; + use bevy::prelude::FloatExt; + + #[test] + fn test_axis_value_bounds() { + fn test_bounds(bounds: AxisBounds, min: f32, max: f32) { + assert_eq!(bounds.min(), min); + assert_eq!(bounds.max(), max); + assert_eq!(bounds.min_max(), (min, max)); + + for value in -300..300 { + let value = value as f32 * 0.01; + + if min <= value && value <= max { + assert!(bounds.contains(value)); + } else { + assert!(!bounds.contains(value)); + } + + assert_eq!(bounds.process(value), value.clamp(min, max)); + } + } + + let bounds = AxisBounds::FULL_RANGE; + test_bounds(bounds, f32::MIN, f32::MAX); + + let bounds = AxisBounds::default(); + test_bounds(bounds, -1.0, 1.0); + + let bounds = AxisBounds::new(-2.0, 2.5); + test_bounds(bounds, -2.0, 2.5); + + let bounds = AxisBounds::magnitude(2.0); + test_bounds(bounds, -2.0, 2.0); + + let bounds = AxisBounds::at_least(-1.0); + test_bounds(bounds, -1.0, f32::MAX); + + let bounds = AxisBounds::at_most(1.5); + test_bounds(bounds, f32::MIN, 1.5); + } + + #[test] + fn test_axis_exclusion() { + fn test_exclusion(exclusion: AxisExclusion, min: f32, max: f32) { + assert_eq!(exclusion.min(), min); + assert_eq!(exclusion.max(), max); + assert_eq!(exclusion.min_max(), (min, max)); + + for value in -300..300 { + let value = value as f32 * 0.01; + + if min <= value && value <= max { + assert!(exclusion.contains(value)); + assert_eq!(exclusion.process(value), 0.0); + } else { + assert!(!exclusion.contains(value)); + assert_eq!(exclusion.process(value), value); + } + } + } + + let exclusion = AxisExclusion::ZERO; + test_exclusion(exclusion, 0.0, 0.0); + + let exclusion = AxisExclusion::default(); + test_exclusion(exclusion, -0.1, 0.1); + + let exclusion = AxisExclusion::new(-2.0, 2.5); + test_exclusion(exclusion, -2.0, 2.5); + + let exclusion = AxisExclusion::magnitude(1.5); + test_exclusion(exclusion, -1.5, 1.5); + } + + #[test] + fn test_axis_deadzone() { + fn test_deadzone(deadzone: AxisDeadZone, min: f32, max: f32) { + let exclusion = deadzone.exclusion(); + assert_eq!(exclusion.min_max(), (min, max)); + + assert_eq!(deadzone.livezone_lower_min_max(), (-1.0, min)); + let width_recip = (min + 1.0).recip(); + assert!((deadzone.livezone_lower_recip - width_recip).abs() <= f32::EPSILON); + + assert_eq!(deadzone.livezone_upper_min_max(), (max, 1.0)); + let width_recip = (1.0 - max).recip(); + assert!((deadzone.livezone_upper_recip - width_recip).abs() <= f32::EPSILON); + + assert_eq!(AxisExclusion::new(min, max).scaled(), deadzone); + + for value in -300..300 { + let value = value as f32 * 0.01; + + // Values within the dead zone are treated as zero. + if min <= value && value <= max { + assert!(deadzone.within_exclusion(value)); + assert_eq!(deadzone.process(value), 0.0); + } + // Values within the live zone are scaled linearly. + else if -1.0 <= value && value < min { + assert!(deadzone.within_livezone_lower(value)); + + let expected = f32::inverse_lerp(-1.0, min, value) - 1.0; + let delta = (deadzone.process(value) - expected).abs(); + assert!(delta <= f32::EPSILON); + } else if max < value && value <= 1.0 { + assert!(deadzone.within_livezone_upper(value)); + + let expected = f32::inverse_lerp(max, 1.0, value); + let delta = (deadzone.process(value) - expected).abs(); + assert!(delta <= f32::EPSILON); + } + // Values outside the bounds are restricted to the nearest valid value. + else { + assert!(!deadzone.within_bounds(value)); + assert_eq!(deadzone.process(value), value.clamp(-1.0, 1.0)); + } + } + } + + let deadzone = AxisDeadZone::ZERO; + test_deadzone(deadzone, 0.0, 0.0); + + let deadzone = AxisDeadZone::default(); + test_deadzone(deadzone, -0.1, 0.1); + + let deadzone = AxisDeadZone::new(-0.2, 0.3); + test_deadzone(deadzone, -0.2, 0.3); + + let deadzone = AxisDeadZone::magnitude(0.4); + test_deadzone(deadzone, -0.4, 0.4); + } +} diff --git a/src/input_streams.rs b/src/input_streams.rs index 7ad45c05..a63a895a 100644 --- a/src/input_streams.rs +++ b/src/input_streams.rs @@ -12,8 +12,7 @@ use bevy::math::Vec2; use bevy::utils::HashSet; use crate::axislike::{ - deadzone_axis_value, AxisType, DualAxisData, MouseMotionAxisType, MouseWheelAxisType, - SingleAxis, + AxisType, DualAxisData, MouseMotionAxisType, MouseWheelAxisType, SingleAxis, }; use crate::buttonlike::{MouseMotionDirection, MouseWheelDirection}; use crate::prelude::DualAxis; @@ -80,7 +79,7 @@ impl<'a> InputStreams<'a> { pub fn input_pressed(&self, input: &UserInput) -> bool { match input { UserInput::Chord(buttons) => self.all_buttons_pressed(buttons), - _ => input.iter().any(|button| self.button_pressed(button)), + _ => input.iter().any(|button| self.button_pressed(&button)), } } @@ -92,39 +91,30 @@ impl<'a> InputStreams<'a> { /// Is the `button` pressed? #[must_use] - pub fn button_pressed(&self, button: InputKind) -> bool { + pub fn button_pressed(&self, button: &InputKind) -> bool { match button { InputKind::DualAxis(axis) => self - .extract_dual_axis_data(&axis) + .extract_dual_axis_data(axis) .is_some_and(|data| data.xy() != Vec2::ZERO), - InputKind::SingleAxis(axis) => { - let value = self.input_value(&button.into(), false); - - value < axis.negative_low || value > axis.positive_low + InputKind::SingleAxis(_) => { + let input: UserInput = button.clone().into(); + self.input_value(&input) != 0.0 } InputKind::GamepadButton(button_type) => { - if let Some(gamepad) = self.associated_gamepad { - return self.gamepad_buttons.pressed(GamepadButton { - gamepad, - button_type, - }); - } - - for gamepad in self.gamepads.iter() { - if self.gamepad_buttons.pressed(GamepadButton { + let button_pressed = |gamepad: Gamepad| -> bool { + self.gamepad_buttons.pressed(GamepadButton { gamepad, - button_type, - }) { - // Return early if ANY gamepad is pressing this button - return true; - } + button_type: *button_type, + }) + }; + if let Some(gamepad) = self.associated_gamepad { + button_pressed(gamepad) + } else { + self.gamepads.iter().any(button_pressed) } - - // If we don't have the required data, fall back to false - false } InputKind::PhysicalKey(keycode) => { - matches!(self.keycodes, Some(keycodes) if keycodes.pressed(keycode)) + matches!(self.keycodes, Some(keycodes) if keycodes.pressed(*keycode)) } InputKind::Modifier(modifier) => { let key_codes = modifier.key_codes(); @@ -132,7 +122,7 @@ impl<'a> InputStreams<'a> { matches!(self.keycodes, Some(keycodes) if keycodes.pressed(key_codes[0]) | keycodes.pressed(key_codes[1])) } InputKind::Mouse(mouse_button) => { - matches!(self.mouse_buttons, Some(mouse_buttons) if mouse_buttons.pressed(mouse_button)) + matches!(self.mouse_buttons, Some(mouse_buttons) if mouse_buttons.pressed(*mouse_button)) } InputKind::MouseWheel(mouse_wheel_direction) => { let Some(mouse_wheel) = &self.mouse_wheel else { @@ -171,7 +161,7 @@ impl<'a> InputStreams<'a> { /// Are all the `buttons` pressed? #[must_use] pub fn all_buttons_pressed(&self, buttons: &[InputKind]) -> bool { - buttons.iter().all(|button| self.button_pressed(*button)) + buttons.iter().all(|button| self.button_pressed(button)) } /// Get the "value" of the input. @@ -186,31 +176,9 @@ impl<'a> InputStreams<'a> { /// /// If you need to ensure that this value is always in the range `[-1., 1.]`, /// be sure to clamp the returned data. - pub fn input_value(&self, input: &UserInput, include_deadzone: bool) -> f32 { + pub fn input_value(&self, input: &UserInput) -> f32 { let use_button_value = || -> f32 { f32::from(self.input_pressed(input)) }; - // 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, mut value: f32| -> f32 { - if include_deadzone { - if value >= axis.negative_low && value <= axis.positive_low { - return 0.0; - } - - let deadzone = if value.is_sign_positive() { - axis.positive_low.abs() - } else { - axis.negative_low.abs() - }; - value = deadzone_axis_value(value, deadzone); - } - if axis.inverted { - value *= -1.0; - } - - value * axis.sensitivity - }; - match input { UserInput::Single(InputKind::SingleAxis(single_axis)) => { match single_axis.axis_type { @@ -222,13 +190,13 @@ impl<'a> InputStreams<'a> { }; if let Some(gamepad) = self.associated_gamepad { let value = get_gamepad_value(gamepad); - value_in_axis_range(single_axis, value) + single_axis.input_value(value) } else { self.gamepads .iter() .map(get_gamepad_value) .find(|value| *value != 0.0) - .map_or(0.0, |value| value_in_axis_range(single_axis, value)) + .map_or(0.0, |value| single_axis.input_value(value)) } } AxisType::MouseWheel(axis_type) => { @@ -245,7 +213,7 @@ impl<'a> InputStreams<'a> { MouseWheelAxisType::X => x, MouseWheelAxisType::Y => y, }; - value_in_axis_range(single_axis, movement) + single_axis.input_value(movement) } AxisType::MouseMotion(axis_type) => { // The compiler will compile this into a direct f64 accumulation when opt-level >= 1. @@ -254,12 +222,13 @@ impl<'a> InputStreams<'a> { MouseMotionAxisType::X => x, MouseMotionAxisType::Y => y, }; - value_in_axis_range(single_axis, movement) + single_axis.input_value(movement) } } } UserInput::VirtualAxis(axis) => { - self.extract_single_axis_data(&axis.positive, &axis.negative) + let data = self.extract_single_axis_data(&axis.positive, &axis.negative); + axis.input_value(data) } UserInput::Single(InputKind::DualAxis(_)) => { self.input_axis_pair(input).unwrap_or_default().length() @@ -276,15 +245,15 @@ impl<'a> InputStreams<'a> { value += match input { InputKind::SingleAxis(axis) => { has_axis = true; - self.input_value(&InputKind::SingleAxis(*axis).into(), true) + self.input_value(&InputKind::SingleAxis(axis.clone()).into()) } InputKind::MouseWheel(axis) => { has_axis = true; - self.input_value(&InputKind::MouseWheel(*axis).into(), true) + self.input_value(&InputKind::MouseWheel(*axis).into()) } InputKind::MouseMotion(axis) => { has_axis = true; - self.input_value(&InputKind::MouseMotion(*axis).into(), true) + self.input_value(&InputKind::MouseMotion(*axis).into()) } _ => 0.0, } @@ -349,24 +318,27 @@ impl<'a> InputStreams<'a> { UserInput::VirtualDPad(dpad) => { let x = self.extract_single_axis_data(&dpad.right, &dpad.left); let y = self.extract_single_axis_data(&dpad.up, &dpad.down); - Some(DualAxisData::new(x, y)) + + let data = dpad.input_value(Vec2::new(x, y)); + Some(DualAxisData::from_xy(data)) } _ => None, } } fn extract_single_axis_data(&self, positive: &InputKind, negative: &InputKind) -> f32 { - let positive = self.input_value(&UserInput::Single(*positive), true); - let negative = self.input_value(&UserInput::Single(*negative), true); + let positive = self.input_value(&UserInput::Single(positive.clone())); + let negative = self.input_value(&UserInput::Single(negative.clone())); positive.abs() - negative.abs() } fn extract_dual_axis_data(&self, dual_axis: &DualAxis) -> Option { - let x = self.input_value(&dual_axis.x.into(), false); - let y = self.input_value(&dual_axis.y.into(), false); + let x = self.input_value(&SingleAxis::new(dual_axis.x_axis_type).into()); + let y = self.input_value(&SingleAxis::new(dual_axis.y_axis_type).into()); - dual_axis.deadzone.deadzone_input_value(x, y) + let data = dual_axis.input_value(Vec2::new(x, y)); + Some(DualAxisData::from_xy(data)) } } diff --git a/src/lib.rs b/src/lib.rs index 00b2954a..5abaa917 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,4 @@ #![forbid(missing_docs)] -#![forbid(unsafe_code)] #![warn(clippy::doc_markdown)] #![doc = include_str!("../README.md")] @@ -20,11 +19,13 @@ mod display_impl; pub mod errors; pub mod input_map; pub mod input_mocking; +pub mod input_processing; pub mod input_streams; pub mod orientation; pub mod plugin; pub mod systems; pub mod timing; +pub mod typetag; pub mod user_input; // Importing the derive macro @@ -34,20 +35,21 @@ pub use leafwing_input_manager_macros::Actionlike; pub mod prelude { pub use crate::action_driver::ActionStateDriver; pub use crate::action_state::ActionState; - pub use crate::axislike::{ - DeadZoneShape, DualAxis, MouseWheelAxisType, SingleAxis, VirtualAxis, VirtualDPad, - }; + pub use crate::axislike::{DualAxis, MouseWheelAxisType, SingleAxis, VirtualAxis, VirtualDPad}; pub use crate::buttonlike::MouseWheelDirection; pub use crate::clashing_inputs::ClashStrategy; pub use crate::input_map::InputMap; #[cfg(feature = "ui")] pub use crate::input_mocking::MockUIInteraction; pub use crate::input_mocking::{MockInput, QueryInput}; + pub use crate::input_processing::*; pub use crate::user_input::{InputKind, Modifier, UserInput}; pub use crate::plugin::InputManagerPlugin; pub use crate::plugin::ToggleActions; pub use crate::{Actionlike, InputManagerBundle}; + + pub use leafwing_input_manager_macros::serde_typetag; } /// Allows a type to be used as a gameplay action in an input-agnostic fashion diff --git a/src/plugin.rs b/src/plugin.rs index 89a5c65d..a596e1a8 100644 --- a/src/plugin.rs +++ b/src/plugin.rs @@ -2,12 +2,13 @@ use crate::action_state::{ActionData, ActionState}; use crate::axislike::{ - AxisType, DeadZoneShape, DualAxis, DualAxisData, MouseMotionAxisType, MouseWheelAxisType, - SingleAxis, VirtualAxis, VirtualDPad, + AxisType, DualAxis, DualAxisData, MouseMotionAxisType, MouseWheelAxisType, SingleAxis, + VirtualAxis, VirtualDPad, }; use crate::buttonlike::{MouseMotionDirection, MouseWheelDirection}; use crate::clashing_inputs::ClashStrategy; use crate::input_map::InputMap; +use crate::input_processing::*; use crate::timing::Timing; use crate::user_input::{InputKind, Modifier, UserInput}; use crate::Actionlike; @@ -176,10 +177,25 @@ impl Plugin for InputManagerPlugin { .register_type::() .register_type::() .register_type::() - .register_type::() .register_type::() .register_type::() .register_type::() + // Processors + .register_axis_processor::() + .register_axis_processor::() + .register_axis_processor::() + .register_axis_processor::() + .register_axis_processor::() + .register_axis_processor::() + .register_dual_axis_processor::() + .register_dual_axis_processor::() + .register_dual_axis_processor::() + .register_dual_axis_processor::() + .register_dual_axis_processor::() + .register_dual_axis_processor::() + .register_dual_axis_processor::() + .register_dual_axis_processor::() + .register_dual_axis_processor::() // Resources .init_resource::>() .init_resource::(); @@ -229,7 +245,7 @@ impl Default for ToggleActions { pub enum InputManagerSystem { /// Advances action timers. /// - /// Cleans up the state of the input manager, clearing `just_pressed` and just_released` + /// Cleans up the state of the input manager, clearing `just_pressed` and `just_released` Tick, /// Collects input data to update the [`ActionState`] Update, diff --git a/src/typetag.rs b/src/typetag.rs new file mode 100644 index 00000000..eac99193 --- /dev/null +++ b/src/typetag.rs @@ -0,0 +1,9 @@ +//! Type tag registration for trait objects + +pub use serde_flexitos::{MapRegistry, Registry}; + +/// A trait for registering type tags. +pub trait RegisterTypeTag<'de, T: ?Sized> { + /// Registers the specified type tag into the [`MapRegistry`]. + fn register_typetag(registry: &mut MapRegistry); +} diff --git a/src/user_input.rs b/src/user_input.rs index 36273590..4a362c97 100644 --- a/src/user_input.rs +++ b/src/user_input.rs @@ -27,7 +27,7 @@ pub enum UserInput { // So a vec it is! // RIP your uniqueness guarantees Chord(Vec), - /// A virtual DPad that you can get an [`DualAxis`] from + /// A virtual D-pad that you can get a [`DualAxis`] from VirtualDPad(VirtualDPad), /// A virtual axis that you can get a [`SingleAxis`] from VirtualAxis(VirtualAxis), @@ -53,7 +53,7 @@ impl UserInput { let vec: Vec = inputs.into_iter().map(|input| input.into()).collect(); match vec.len() { - 1 => UserInput::Single(vec[0]), + 1 => UserInput::Single(vec[0].clone()), _ => UserInput::Chord(vec), } } @@ -110,16 +110,16 @@ impl UserInput { pub(crate) fn iter(&self) -> UserInputIter { match self { - UserInput::Single(button) => UserInputIter::Single(Some(*button)), + UserInput::Single(button) => UserInputIter::Single(Some(button.clone())), UserInput::Chord(buttons) => UserInputIter::Chord(buttons.iter()), - UserInput::VirtualDPad(dpad) => UserInputIter::VirtualDpad( - Some(dpad.up), - Some(dpad.down), - Some(dpad.left), - Some(dpad.right), + UserInput::VirtualDPad(dpad) => UserInputIter::VirtualDPad( + Some(dpad.up.clone()), + Some(dpad.down.clone()), + Some(dpad.left.clone()), + Some(dpad.right.clone()), ), UserInput::VirtualAxis(axis) => { - UserInputIter::VirtualAxis(Some(axis.negative), Some(axis.positive)) + UserInputIter::VirtualAxis(Some(axis.negative.clone()), Some(axis.positive.clone())) } } } @@ -128,7 +128,7 @@ impl UserInput { pub(crate) enum UserInputIter<'a> { Single(Option), Chord(std::slice::Iter<'a, InputKind>), - VirtualDpad( + VirtualDPad( Option, Option, Option, @@ -143,8 +143,8 @@ impl<'a> Iterator for UserInputIter<'a> { fn next(&mut self) -> Option { match self { Self::Single(ref mut input) => input.take(), - Self::Chord(ref mut iter) => iter.next().copied(), - Self::VirtualDpad(ref mut up, ref mut down, ref mut left, ref mut right) => up + Self::Chord(ref mut iter) => iter.next().cloned(), + Self::VirtualDPad(ref mut up, ref mut down, ref mut left, ref mut right) => up .take() .or_else(|| down.take().or_else(|| left.take().or_else(|| right.take()))), Self::VirtualAxis(ref mut negative, ref mut positive) => { @@ -229,7 +229,7 @@ impl From for UserInput { /// /// Please contact the maintainers if you need support for another type! #[non_exhaustive] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] +#[derive(Debug, Clone, PartialEq, Eq, Hash, Reflect, Serialize, Deserialize)] pub enum InputKind { /// A button on a gamepad GamepadButton(GamepadButtonType), @@ -352,22 +352,27 @@ pub struct RawInputs { impl RawInputs { /// Merges the data from the given `input_kind` into `self`. fn merge_input_data(&mut self, input_kind: &InputKind) { - match *input_kind { - InputKind::DualAxis(DualAxis { x, y, .. }) => { - self.axis_data.push((x.axis_type, x.value)); - self.axis_data.push((y.axis_type, y.value)); + match input_kind { + InputKind::DualAxis(DualAxis { + x_axis_type, + y_axis_type, + value, + .. + }) => { + self.axis_data.push((*x_axis_type, value.map(|v| v.x))); + self.axis_data.push((*y_axis_type, value.map(|v| v.y))); } InputKind::SingleAxis(single_axis) => self .axis_data .push((single_axis.axis_type, single_axis.value)), - InputKind::GamepadButton(button) => self.gamepad_buttons.push(button), - InputKind::PhysicalKey(key_code) => self.keycodes.push(key_code), + InputKind::GamepadButton(button) => self.gamepad_buttons.push(*button), + InputKind::PhysicalKey(key_code) => self.keycodes.push(*key_code), InputKind::Modifier(modifier) => { self.keycodes.extend_from_slice(&modifier.key_codes()); } - InputKind::Mouse(button) => self.mouse_buttons.push(button), - InputKind::MouseWheel(button) => self.mouse_wheel.push(button), - InputKind::MouseMotion(button) => self.mouse_motion.push(button), + InputKind::Mouse(button) => self.mouse_buttons.push(*button), + InputKind::MouseWheel(button) => self.mouse_wheel.push(*button), + InputKind::MouseMotion(button) => self.mouse_motion.push(*button), } } } @@ -412,8 +417,8 @@ impl RawInputs { fn from_dual_axis(axis: DualAxis) -> RawInputs { RawInputs { axis_data: vec![ - (axis.x.axis_type, axis.x.value), - (axis.y.axis_type, axis.y.value), + (axis.x_axis_type, axis.value.map(|v| v.x)), + (axis.y_axis_type, axis.value.map(|v| v.y)), ], ..Default::default() } @@ -456,7 +461,7 @@ mod raw_input_tests { let chord = UserInput::chord([ InputKind::GamepadButton(GamepadButtonType::Start), - InputKind::SingleAxis(SingleAxis::symmetric(GamepadAxisType::LeftZ, 0.)), + InputKind::SingleAxis(SingleAxis::new(GamepadAxisType::LeftZ)), ]); let raw = chord.raw_inputs(); @@ -488,7 +493,7 @@ mod raw_input_tests { use bevy::input::gamepad::GamepadAxisType; let direction = SingleAxis::from_value(GamepadAxisType::LeftStickX, 1.0); - let expected = RawInputs::from_single_axis(direction); + let expected = RawInputs::from_single_axis(direction.clone()); let raw = UserInput::from(direction).raw_inputs(); assert_eq!(expected, raw) } @@ -504,7 +509,7 @@ mod raw_input_tests { 0.5, 0.7, ); - let expected = RawInputs::from_dual_axis(direction); + let expected = RawInputs::from_dual_axis(direction.clone()); let raw = UserInput::from(direction).raw_inputs(); assert_eq!(expected, raw) } @@ -576,7 +581,7 @@ mod raw_input_tests { use crate::axislike::{MouseWheelAxisType, SingleAxis}; let direction = SingleAxis::from_value(MouseWheelAxisType::X, 1.0); - let expected = RawInputs::from_single_axis(direction); + let expected = RawInputs::from_single_axis(direction.clone()); let raw = UserInput::from(direction).raw_inputs(); assert_eq!(expected, raw) } @@ -587,7 +592,7 @@ mod raw_input_tests { let direction = DualAxis::from_value(MouseWheelAxisType::X, MouseWheelAxisType::Y, 1.0, 1.0); - let expected = RawInputs::from_dual_axis(direction); + let expected = RawInputs::from_dual_axis(direction.clone()); let raw = UserInput::from(direction).raw_inputs(); assert_eq!(expected, raw) } @@ -597,7 +602,7 @@ mod raw_input_tests { use crate::axislike::{MouseMotionAxisType, SingleAxis}; let direction = SingleAxis::from_value(MouseMotionAxisType::X, 1.0); - let expected = RawInputs::from_single_axis(direction); + let expected = RawInputs::from_single_axis(direction.clone()); let raw = UserInput::from(direction).raw_inputs(); assert_eq!(expected, raw) } @@ -608,7 +613,7 @@ mod raw_input_tests { let direction = DualAxis::from_value(MouseMotionAxisType::X, MouseMotionAxisType::Y, 1.0, 1.0); - let expected = RawInputs::from_dual_axis(direction); + let expected = RawInputs::from_dual_axis(direction.clone()); let raw = UserInput::from(direction).raw_inputs(); assert_eq!(expected, raw) } diff --git a/tests/gamepad_axis.rs b/tests/gamepad_axis.rs index 85805c82..5a831d98 100644 --- a/tests/gamepad_axis.rs +++ b/tests/gamepad_axis.rs @@ -3,7 +3,7 @@ use bevy::input::gamepad::{ }; use bevy::input::InputPlugin; use bevy::prelude::*; -use leafwing_input_manager::axislike::{AxisType, DeadZoneShape, DualAxisData}; +use leafwing_input_manager::axislike::{AxisType, DualAxisData}; use leafwing_input_manager::prelude::*; #[derive(Actionlike, Clone, Copy, Debug, Reflect, PartialEq, Eq, Hash)] @@ -53,7 +53,7 @@ fn raw_gamepad_axis_events() { let mut app = test_app(); app.insert_resource(InputMap::new([( ButtonlikeTestAction::Up, - SingleAxis::symmetric(GamepadAxisType::RightStickX, 0.1), + SingleAxis::new(GamepadAxisType::RightStickX).with_processor(AxisDeadZone::default()), )])); let mut events = app.world.resource_mut::>(); @@ -78,10 +78,7 @@ fn game_pad_single_axis_mocking() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - sensitivity: 1.0, - inverted: false, + processor: None, }; app.send_input(input); @@ -96,24 +93,12 @@ fn game_pad_dual_axis_mocking() { let mut events = app.world.resource_mut::>(); assert_eq!(events.drain().count(), 0); + let deadzone = CircleDeadZone::default(); let input = DualAxis { - x: SingleAxis { - axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), - value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - sensitivity: 1.0, - inverted: false, - }, - y: SingleAxis { - axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), - value: Some(0.), - positive_low: 0.0, - negative_low: 0.0, - sensitivity: 1.0, - inverted: false, - }, - deadzone: DualAxis::DEFAULT_DEADZONE_SHAPE, + x_axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), + y_axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), + processor: Some(Box::new(deadzone)), + value: Some(Vec2::X), }; app.send_input(input); let mut events = app.world.resource_mut::>(); @@ -124,14 +109,15 @@ fn game_pad_dual_axis_mocking() { #[test] fn game_pad_single_axis() { let mut app = test_app(); + let deadzone = AxisDeadZone::default(); app.insert_resource(InputMap::new([ ( AxislikeTestAction::X, - SingleAxis::symmetric(GamepadAxisType::LeftStickX, 0.1), + SingleAxis::new(GamepadAxisType::LeftStickX).with_processor(deadzone), ), ( AxislikeTestAction::Y, - SingleAxis::symmetric(GamepadAxisType::LeftStickY, 0.1), + SingleAxis::new(GamepadAxisType::LeftStickY).with_processor(deadzone), ), ])); @@ -139,10 +125,7 @@ fn game_pad_single_axis() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -153,10 +136,7 @@ fn game_pad_single_axis() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -167,10 +147,7 @@ fn game_pad_single_axis() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -181,10 +158,7 @@ fn game_pad_single_axis() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -192,14 +166,12 @@ fn game_pad_single_axis() { assert!(action_state.pressed(&AxislikeTestAction::Y)); // 0 + // Usually a small deadzone threshold will be set + let deadzone = AxisDeadZone::default(); let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), value: Some(0.0), - // Usually a small deadzone threshold will be set - positive_low: 0.1, - negative_low: 0.1, - inverted: false, - sensitivity: 1.0, + processor: Some(Box::new(deadzone)), }; app.send_input(input); app.update(); @@ -210,10 +182,7 @@ fn game_pad_single_axis() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), value: None, - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -221,13 +190,11 @@ fn game_pad_single_axis() { assert!(!action_state.pressed(&AxislikeTestAction::Y)); // Scaled value + let deadzone = AxisDeadZone::default(); 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, + processor: Some(Box::new(deadzone)), }; app.send_input(input); app.update(); @@ -239,14 +206,17 @@ fn game_pad_single_axis() { #[test] fn game_pad_single_axis_inverted() { let mut app = test_app(); + let processors = AxisProcessingPipeline::default() + .with(AxisExclusion::default()) + .with(AxisInverted); app.insert_resource(InputMap::new([ ( AxislikeTestAction::X, - SingleAxis::symmetric(GamepadAxisType::LeftStickX, 0.1).inverted(), + SingleAxis::new(GamepadAxisType::LeftStickX).with_processor(processors.clone()), ), ( AxislikeTestAction::Y, - SingleAxis::symmetric(GamepadAxisType::LeftStickY, 0.1).inverted(), + SingleAxis::new(GamepadAxisType::LeftStickY).with_processor(processors), ), ])); @@ -254,12 +224,8 @@ fn game_pad_single_axis_inverted() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: true, - sensitivity: -1.0, - } - .inverted(); + processor: Some(Box::new(AxisInverted)), + }; app.send_input(input); app.update(); let action_state = app.world.resource::>(); @@ -270,10 +236,7 @@ fn game_pad_single_axis_inverted() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickX), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: true, - sensitivity: -1.0, + processor: None, }; app.send_input(input); app.update(); @@ -285,10 +248,7 @@ fn game_pad_single_axis_inverted() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: true, - sensitivity: -1.0, + processor: None, }; app.send_input(input); app.update(); @@ -300,10 +260,7 @@ fn game_pad_single_axis_inverted() { let input = SingleAxis { axis_type: AxisType::Gamepad(GamepadAxisType::LeftStickY), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: true, - sensitivity: -1.0, + processor: None, }; app.send_input(input); app.update(); @@ -313,17 +270,15 @@ fn game_pad_single_axis_inverted() { } #[test] -fn game_pad_dual_axis_cross() { +fn game_pad_dual_axis_deadzone() { let mut app = test_app(); + let deadzone = DualAxisDeadZone::default(); app.insert_resource(InputMap::new([( AxislikeTestAction::XY, - DualAxis::left_stick().with_deadzone(DeadZoneShape::Cross { - horizontal_width: 0.1, - vertical_width: 0.1, - }), + DualAxis::left_stick().replace_processor(deadzone), )])); - // Test that an input inside the cross deadzone is filtered out. + // Test that an input inside the dual-axis deadzone is filtered out. app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, @@ -341,7 +296,7 @@ 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 dual-axis deadzone is not filtered out. app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, @@ -359,7 +314,7 @@ fn game_pad_dual_axis_cross() { DualAxisData::new(1.0, 0.11111112) ); - // Test that each axis of the cross deadzone is filtered independently. + // Test that each axis of the dual-axis deadzone is filtered independently. app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, @@ -379,17 +334,15 @@ fn game_pad_dual_axis_cross() { } #[test] -fn game_pad_dual_axis_ellipse() { +fn game_pad_circle_deadzone() { let mut app = test_app(); + let deadzone = CircleDeadZone::default(); app.insert_resource(InputMap::new([( AxislikeTestAction::XY, - DualAxis::left_stick().with_deadzone(DeadZoneShape::Ellipse { - radius_x: 0.1, - radius_y: 0.1, - }), + DualAxis::left_stick().replace_processor(deadzone), )])); - // Test that an input inside the ellipse deadzone is filtered out, assuming values of 0.1 + // Test that an input inside the circle deadzone is filtered out, assuming values of 0.1 app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, @@ -407,7 +360,7 @@ fn game_pad_dual_axis_ellipse() { DualAxisData::new(0.0, 0.0) ); - // Test that an input outside the ellipse deadzone is not filtered out, assuming values of 0.1 + // Test that an input outside the circle deadzone is not filtered out, assuming values of 0.1 app.send_input(DualAxis::from_value( GamepadAxisType::LeftStickX, GamepadAxisType::LeftStickY, @@ -427,14 +380,12 @@ fn game_pad_dual_axis_ellipse() { } #[test] -fn test_zero_cross() { +fn test_zero_dual_axis_deadzone() { let mut app = test_app(); + let deadzone = DualAxisDeadZone::ZERO; app.insert_resource(InputMap::new([( AxislikeTestAction::XY, - DualAxis::left_stick().with_deadzone(DeadZoneShape::Cross { - horizontal_width: 0.0, - vertical_width: 0.0, - }), + DualAxis::left_stick().with_processor(deadzone), )])); // Test that an input of zero will be `None` even with no deadzone. @@ -457,14 +408,12 @@ fn test_zero_cross() { } #[test] -fn test_zero_ellipse() { +fn test_zero_circle_deadzone() { let mut app = test_app(); + let deadzone = CircleDeadZone::ZERO; app.insert_resource(InputMap::new([( AxislikeTestAction::XY, - DualAxis::left_stick().with_deadzone(DeadZoneShape::Ellipse { - radius_x: 0.0, - radius_y: 0.0, - }), + DualAxis::left_stick().with_processor(deadzone), )])); // Test that an input of zero will be `None` even with no deadzone. @@ -488,7 +437,7 @@ fn test_zero_ellipse() { #[test] #[ignore = "Input mocking is subtly broken: https://github.com/Leafwing-Studios/leafwing-input-manager/issues/407"] -fn game_pad_virtualdpad() { +fn game_pad_virtual_dpad() { let mut app = test_app(); app.insert_resource(InputMap::new([( AxislikeTestAction::XY, diff --git a/tests/mouse_motion.rs b/tests/mouse_motion.rs index 5643d04b..47357f80 100644 --- a/tests/mouse_motion.rs +++ b/tests/mouse_motion.rs @@ -4,7 +4,6 @@ use bevy::prelude::*; use leafwing_input_manager::axislike::{AxisType, DualAxisData, MouseMotionAxisType}; use leafwing_input_manager::buttonlike::MouseMotionDirection; use leafwing_input_manager::prelude::*; -use leafwing_input_manager::user_input::InputKind; #[derive(Actionlike, Clone, Copy, Debug, Reflect, PartialEq, Eq, Hash)] enum ButtonlikeTestAction { @@ -83,10 +82,7 @@ fn mouse_motion_single_axis_mocking() { let input = SingleAxis { axis_type: AxisType::MouseMotion(MouseMotionAxisType::X), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); @@ -101,23 +97,10 @@ fn mouse_motion_dual_axis_mocking() { assert_eq!(events.drain().count(), 0); let input = DualAxis { - x: SingleAxis { - axis_type: AxisType::MouseMotion(MouseMotionAxisType::X), - value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, - }, - y: SingleAxis { - axis_type: AxisType::MouseMotion(MouseMotionAxisType::Y), - value: Some(0.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, - }, - deadzone: DualAxis::ZERO_DEADZONE_SHAPE, + x_axis_type: AxisType::MouseMotion(MouseMotionAxisType::X), + y_axis_type: AxisType::MouseMotion(MouseMotionAxisType::Y), + processor: None, + value: Some(Vec2::X), }; app.send_input(input); let mut events = app.world.resource_mut::>(); @@ -182,10 +165,7 @@ fn mouse_motion_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseMotion(MouseMotionAxisType::X), value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -196,10 +176,7 @@ fn mouse_motion_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseMotion(MouseMotionAxisType::X), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -210,10 +187,7 @@ fn mouse_motion_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseMotion(MouseMotionAxisType::Y), value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -224,10 +198,7 @@ fn mouse_motion_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseMotion(MouseMotionAxisType::Y), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -235,14 +206,12 @@ fn mouse_motion_single_axis() { assert!(action_state.pressed(&AxislikeTestAction::Y)); // 0 + // Usually a small deadzone threshold will be set + let deadzone = AxisDeadZone::default(); let input = SingleAxis { axis_type: AxisType::MouseMotion(MouseMotionAxisType::Y), value: Some(0.0), - // Usually a small deadzone threshold will be set - positive_low: 0.1, - negative_low: 0.1, - inverted: false, - sensitivity: 1.0, + processor: Some(Box::new(deadzone)), }; app.send_input(input); app.update(); @@ -253,10 +222,7 @@ fn mouse_motion_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseMotion(MouseMotionAxisType::Y), value: None, - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -292,7 +258,7 @@ fn mouse_motion_dual_axis() { } #[test] -fn mouse_motion_virtualdpad() { +fn mouse_motion_virtual_dpad() { let mut app = test_app(); app.insert_resource(InputMap::new([( AxislikeTestAction::XY, diff --git a/tests/mouse_wheel.rs b/tests/mouse_wheel.rs index 478d341e..0d17b180 100644 --- a/tests/mouse_wheel.rs +++ b/tests/mouse_wheel.rs @@ -1,7 +1,7 @@ use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::input::InputPlugin; use bevy::prelude::*; -use leafwing_input_manager::axislike::{AxisType, DualAxisData, MouseWheelAxisType}; +use leafwing_input_manager::axislike::{AxisType, DualAxisData}; use leafwing_input_manager::prelude::*; #[derive(Actionlike, Clone, Copy, Debug, Reflect, PartialEq, Eq, Hash)] @@ -79,10 +79,7 @@ fn mouse_wheel_single_axis_mocking() { let input = SingleAxis { axis_type: AxisType::MouseWheel(MouseWheelAxisType::X), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); @@ -97,23 +94,10 @@ fn mouse_wheel_dual_axis_mocking() { assert_eq!(events.drain().count(), 0); let input = DualAxis { - x: SingleAxis { - axis_type: AxisType::MouseWheel(MouseWheelAxisType::X), - value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, - }, - y: SingleAxis { - axis_type: AxisType::MouseWheel(MouseWheelAxisType::Y), - value: Some(0.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, - }, - deadzone: DualAxis::ZERO_DEADZONE_SHAPE, + x_axis_type: AxisType::MouseWheel(MouseWheelAxisType::X), + y_axis_type: AxisType::MouseWheel(MouseWheelAxisType::Y), + processor: None, + value: Some(Vec2::X), }; app.send_input(input); let mut events = app.world.resource_mut::>(); @@ -178,10 +162,7 @@ fn mouse_wheel_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseWheel(MouseWheelAxisType::X), value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -192,10 +173,7 @@ fn mouse_wheel_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseWheel(MouseWheelAxisType::X), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -206,10 +184,7 @@ fn mouse_wheel_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseWheel(MouseWheelAxisType::Y), value: Some(1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -220,10 +195,7 @@ fn mouse_wheel_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseWheel(MouseWheelAxisType::Y), value: Some(-1.), - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update(); @@ -231,14 +203,12 @@ fn mouse_wheel_single_axis() { assert!(action_state.pressed(&AxislikeTestAction::Y)); // 0 + // Usually a small deadzone threshold will be set + let deadzone = AxisDeadZone::default(); let input = SingleAxis { axis_type: AxisType::MouseWheel(MouseWheelAxisType::Y), value: Some(0.0), - // Usually a small deadzone threshold will be set - positive_low: 0.1, - negative_low: 0.1, - inverted: false, - sensitivity: 1.0, + processor: Some(Box::new(deadzone)), }; app.send_input(input); app.update(); @@ -249,10 +219,7 @@ fn mouse_wheel_single_axis() { let input = SingleAxis { axis_type: AxisType::MouseWheel(MouseWheelAxisType::Y), value: None, - positive_low: 0.0, - negative_low: 0.0, - inverted: false, - sensitivity: 1.0, + processor: None, }; app.send_input(input); app.update();