wle-input-system
v0.0.3
Published
An input system for Wonderland Engine that manages a wide range of input devices through a dedicated event-based system complemented by polling capabilities
Downloads
18
Maintainers
Readme
Wonderland Engine Input System
This is an easy-to-use and easily extendable Input System for Wonderland Engine capable of managing various types of input devices through a dedicated event-based system complemented by polling capabilities.
Customization
You can extend the system by writing your own Controllers, Controls, Control Activators, Modifiers and Triggers using the provided classes and interfaces.
Performance
Dynamically allocated objects are cached, and instantiated only when needed. Most calculations are performed only in specific circumstances and have been deferred to the initial setup or when a Controller is connected or disconnected, allowing for a lighter update cycle.
Ease of Use and Type Safety
The system prioritizes ease of use and safety with a strongly typed implementation. This approach ensures that all available options are suggested during setup and helps prevent common errors.
Overview
Here's a brief overview of the base components and concepts of the Input System.
Input Manager
The entry point of the system.
- Manages all the Controllers:
- Provides information on the current, connected, and disconnected Controllers and notifies for any related changes.
- Organizes Controllers by category through Controller Managers.
- Organizes Actions through Action Groups.
Controller
Code representation of a physical input device that contains a Control for every readable input.
The Controllers supported by default are: mouse
,touchscreen
,pen
, keyboard
, gamepad
, xr-head
, xr-screen
, xr-gamepad
, xr-hand
, orientaion-sensor
, accelerometer
, gyroscope
, gravity-sensor
, linear-acceleration-sensor
.
Control
Code representation of a readable input.
- Provides the raw value read from the hardware.
- Defines if there have been physical interactions with it (Controls such as positions and rotations are always set as activated and therefore set as State Controls).
Can contain more specific Controls for organization e readability purpose (for example, Thumbstick contains X Axis and Y Axis).
Common Control return types are:
boolean
for buttons pressed or touched state.number
for buttons values and axis.Vector2
for 2D positions and axes.Vector3
for 3D positions and linear/angular accellerations.Vector4
for 3D rotations. (TheVector
types, besides having elements indexed by letters, can also be accessed by numbers, making themArrayLike<number>
as well).
Controller Manager
Manages all the Controllers of a specific category.
- Provides the current Controller
- Provides all the connected Controllers (only for categories that support multiple simultaneous Controllers like
gamepads
) - Notifies for any Controller connected, disconnected, or set as currently in use.
The available managers and sub-managers are:
- Pointer
- Mouse
- Touchscreen
- Pen
- Keyboard
- Gamepad
- XR
- Viewer
- Head
- Screen
- Gamepad
- Left
- Right
- Hand
- Left
- Right
- Viewer
- Sensor
- Orientation
- Accelerometer
- Gyroscope
- Gravity
- Linear Acceleration
Action
The representation of the actual action performed as a result of desired inputs. (select, jump, move, grab, etc.)
- Provides a value by managing the Bindings from which it retrieves the raw input values.
- Defines a state indicating whether the Action is
waiting
for an interaction, isstarted
, isperforming
or isended
, and raises events accordingly.
If there are multiple matching interactions by the user, they are checked in order, from the last to the oldest previously registered, to prevent unexpected behaviors. If there is no matching user interaction, it returns either a default value or the value of the first available matching State Control.
Action Group
A folder-like system for the Actions:
- Stores and organizes Actions.
- Facilitates easy retrieval.
- Allows disabling or pausing specific groups of Actions based on the hierarchy.
Binding
The connection between an Action and a Control.
It's defined by a path that specifies to the system the type of Control from which an Action must retrieve the raw input value.
When multiple Bindings match the same Controller within the same Action, only the ones with the most specific paths for that device are processed. This allows the use of different controls depending on the mapping or model of the same type of controller.
Composite Binding extends this concept by containing multiple paths, even from different Controllers, allowing every possible input combination and interaction.
Converter
Converts the value retrieved by a Bindings to match the type of the related Action.
It can convert the WASD keys Controls to a Vector2
to be compatible with an Action that moves the player, or the position of the index and thumb finger tips to a boolean
when closer than a certain distance for a pinch gesture.
When the Converter returns a boolean
, it can also be set as Control Activator.
Control Activator
Defines the activation requirement of a Control within a Binding.
Composite Bindings also support Composite Control Activators, which define the activation of the entire Binding based on the currently active bound controls.
They are useful in specific cases such as checking the input's Controller in local multiplayer scenarios or managing the behaviour of State Controls.
Modifier
Modifies the returned value of an Action or a Binding without altering their type.
Modifiers applied to the Binding are exclusive to that Binding, while those applied to the Action are applied to every Binding contained within it (after its own, if any).
Common Modifiers include inversion, clamping, scaling, normalization, or deadzone control.
Trigger
Updates the state of an Action based on the behavior of its value.
The default Trigger triggers a state change when the retrieved value of the Action differs from its default value. Other common Triggers include interactions like hold, tap, multi-tap, or sequences of buttons.
Any Converter that returns a boolean can also act as a Trigger.
When assigned to a Binding, it will overwrite the Action's Trigger when that Binding is the active one of the Action.
Usage
Installation
You can import the WLE Input System to your project through npm using the following command:
npm install wle-input-system
Setup
If you're a Wonderland Engine user, you don't have to worry about setting up the Input Manager. The WLInputManager
class handles everything for you: it updates itself through the onPreRender
event and manages all the XR functionality, supporting multi-engine and multi-scene setups.
You can access the Input Manager from anywhere using:
WLInputManager.get(engine);
// or, if you don't need multi-engine support:
WLInputManager.current;
and set it up from the editor through the input-manager-component
(keep in mind that if you're not using it, you should access the Input Manager by providing the current engine at least once to make it work).
You can also enable/disable each category of native Controller from its manager, which is accessible directly from the Input Manager:
// Pointers
// For the pointers you can specify the event target of the PointerEvent
// (e.g. the canvas of your game, by default set to "document")
inputManager.pointer.mouse.native.enable(target);
inputManager.pointer.pen.native.enable(target);
inputManager.pointer.touch.native.enable(target);
// Keyboard
inputManager.keyboard.native.enable()
// Gamepads
inputManager.gamepad.native.enable()
//XR
inputManager.xr.gamepad.native.enable();
inputManager.xr.hand.native.enable();
inputManager.xr.viewer.head.native.enable();
inputManager.xr.viewer.screen.native.enable();
// Sensors
// For performance and battery reasons, it's better to enable them only when needed
inputManager.sensor.orientation.native.enable()
inputManager.sensor.accelerometer.native.enable()
inputManager.sensor.linearAcceleration.native.enable()
inputManager.sensor.gravity.native.enable()
inputManager.sensor.gyroscope.native.enable()
Read a value from a controller
In case you want to get the left xr-gamepad
you're currently using, you can use:
const controller = inputManager.xr.gamepad.left.current;
And, if you want to retrieve the value of the grip button of that Controller in a 0-1 range, you can:
controller.grip.value.readValue();
// or
controller.getControl('grip').value.readValue();
You can also retrieve the last globally used Control with:
const currentController = inputManager.currentController;
and check every Controller category, mapping, vendor or model based on the class:
if(currentController.class === 'xr-gamepad'){
console.log(`this is a ${currentController.model} gamepad!`);
}
In combination with the currentControllerChanged
event, you can, for example, adjust the UI of your game based on the current Controller being used.
Make the controller vibrate
If the Controller implements the HapticController
interface (like the GamepadController
and the VRGamepadController
), you can make it vibrate by:
controller.haptic.start(duration, intensity);
If the hardware is based on two big rumble motors (gamepads
), you can also set the strong and weak intesity parameters to represent the intensity of the actual big and small motors (default set as the same value of strongIntensity
).
gamepad.haptic.start(duration, strongIntensity, weakIntensity);
If the hardware is based on other kind of motors (xr-gamepads
), you can also set the index of the motor you wish to activate (default set to 0
).
gamepad.haptic.start(duration, intensity, index);
If no intensity is passed, the default value would be set to 1
(the maximum).
Unlike the Gamepad API functions on which this system is based, there is no 5-second limit for the duration of the vibration. Thanks to this, you can set is as Infinity
, which is also the default value if no duration is passed.
Take into account that not all the browsers support all type of vibration and that the function may behaves differently depending on the hardware. You can check if the vibration is currently supported with:
controller.haptic.supported;
To make the Controller stop vibrating, you just need to call:
controller.haptic.stop();
Setup an Action
To be able to use an Action, you must first get an Action Group to which you can add it:
// Get an Action Group (if not existing, it will be automatically created)
const baseActions = inputManager.getActionGroup('baseActions');
// or create and add it to the Input Manager
const baseActions = new InputActionGroup('baseActions');
inputManager.addActionGroup(baseActions);
Keep in mind that you can add in the same way an Action Group inside another Action Group:
const subActions = baseActions.getActionGroup('subActions');
// or
const subActions = baseActions.addActionGroup(new InputActionGroup('subActions'));
Than, you can finally add the Action:
// When creating the Action, you have to provide a default value from which it will define its type
const moveAction = new InputAction('move', Vector2.zero); // Returns an InputAction<Vector2>
baseActions.addAction(moveAction);
Now you can create a Binding, so that the Action knows from which Control should retrieve the input value from.
If you want to bind the left gamepad stick with a deadzone you can:
// When creating the Binding you can specify the control and the returning type (one is enough if they are the same)
const moveGamepadBinding = new InputSingleBinding<Vector2>('gamepad-stick')
// Set the path of the controller and the one of the control
.setPath('gamepad', 'leftStick') // Only the controls with the matching type are suggested and allowed
// Add the deadzone modifier
.addModifier('stickDeadZone', 0.2, 0.9); // Only the modifiers witht he matching types are suggested and allowed
// Or you can omit the types and let the Binding define them by itself
const moveGamepadBinding = new InputSingleBinding('gamepad-stick')
.setPath('gamepad', 'leftStick') // All available controls are suggested
// Since the assigned control is a InputControl<Vector2>, the Binding is now a InputSingleBinding<Vector2>
.addModifier('stickDeadZone', 0.2, 0.9); // Only the modifiers with the matching types are suggested and allowed
If you want to bind a direction vector from the WASD keys of the keyboard, you can check for the key values with a Composite Binding and transform them in a two dimensional vector through a compositeVector2
Converter.
Remember to assign the Converter first before proceeding:
// When creating a Composite Binding you can specify the controls types as a Tuple
const moveKeyboardBinding = new InputCompositeBinding<[boolean, boolean, boolean, boolean], Vector2>()
// When the control and the returning types are not equal, you have to assing a converter first
.setConverter('compositeVector2') // Only converters from [boolean, boolean, boolean, boolean] to Vector2 are suggested
// Set the path of the correspoding index
.setPath(0, 'keyboard', 'keyA') // Only boolean based Controls are suggested
.setPath(1, 'keyboard', 'keyD') // Only boolean based Controls are suggested
.setPath(2, 'keyboard', 'keyW') // Only boolean based Controls are suggested
.setPath(3, 'keyboard', 'keyS'); // Only boolean based Controls are suggested
// Or you can omit the types and let the Binding define them automatically
const moveKeyboardBinding = new InputCompositeBinding()
// When omitting the types, you must assing the converter befor setting the paths
.setConverter('compositeVector2') // All available controls are suggested
// Based on the assigned Converter, the Binding becomes an InputConverter<[number | boolean, number | boolean, number | boolean, number | boolean], Vector2>
// Whenever a more specific type is assigned, the Binding type adjusts itself
.setPath(0, 'keyboard', 'keyA') // Only number and boolean based Controls are suggested
// Returns InputConverter<[boolean, number | boolean, number | boolean, number | boolean], Vector2>
.setPath(1, 'keyboard', 'keyD') // Only number and boolean based Controls are suggested
// Returns InputConverter<[boolean, boolean, number | boolean, number | boolean], Vector2>
.setPath(2, 'keyboard', 'keyW') // Only number and boolean based Controls are suggested
// Returns InputConverter<[boolean, boolean, boolean, number | boolean], Vector2>
.setPath(3, 'keyboard', 'keyS'); // Only number and boolean based Controls are suggested
// Returns InputConverter<[boolean, boolean, boolean, boolean], Vector2>
To add a Binding to an action:
moveAction.addBinding(moveKeyboardBinding);
In certain cases, such as when you want a gun to fire a bullet when a button is pressed for a specific duration, you may also want to set a Trigger:
// The action will be triggered when the returned value is true for 0.5 seconds
fireAction.setTrigger('hold', 0.5);
// or
fireAction.setTrigger(new HoldTrigger(0.5));
Note that in rare cases where the Action type does not extend a primitive type or an ArrayLike of a primitive, settings the Trigger is mandatory.
If you want to use an event base approach, you can finally add a callback to the Action based on the current state of it.
moveAction.addEventListener('performing', moveCallback);
For simplicity and readability purpose, all the methods can be chainded together in the way you prefer:
inputManager.getActionGroup('player')
.addAction(new InputAction('move', Vector2.zero)
.addBinding(new InputSingleBinding()
.setPath('gamepad', 'leftStick')
.addModifier('stickDeadZone'))
.addBinding(new InputSingleBinding()
.setPath('gamepad', 'dpad'))
.addBinding(new InputSingleBinding()
.setPath('left/xr-gamepad', 'thumbstick')
.addModifier('stickDeadZone'))
.addBinding(new InputCompositeBinding()
.setConverter('compositeVector2')
.setPath(0, 'keyboard', 'keyA')
.setPath(1, 'keyboard', 'keyD')
.setPath(2, 'keyboard', 'keyW')
.setPath(3, 'keyboard', 'keyS'))
.addEventListener('performing', (action, deltaTime) => {
player.move(
action.readValue().x * deltaTime,
action.readValue().y * deltaTime);
}));
Thanks to the typed nature of the system, all the possible combination are suggested to you in the setup phase, even in javascript.
Take note that you can activate, deactivate, create, remove, assign and modify everything in the order you want and even in the middle of the gameplay with multiple connected Controllers.
Write Control Paths
When adding a Binding to an Action, it will be internally sorted based on the depth of its Control paths. Only the Controls of the most specific paths are bound when a Controller matches multiple paths of the same Action.
For example:
action.addBinding(new InputSingleBinding<bool>()
// The controller path specifies the model
.setPath('gamepad/standard/sony-dualshock-4', 'crossButton'))
.addBinding(new InputSingleBinding<bool>()
// The controller path matches any gamepad
.setPath('gamepad', 'rightFace'))
Even if the DualShock 4 is also a gamepad, it will trigger the action only when the crossButton
is pressed.
You can also use a regular expressions to define a Controller path:
action.addBinding(new InputSingleBinding<bool>()
// The '*' matches any type of that section
// The regular expression is enclosed within curly braces and supports flags
.setPath('gamepad/*/{/sony/}', 'crossButton'))
.addBinding(new InputSingleBinding<bool>()
.setPath('gamepad/*/{/044f/}', 'button22'))
The path can also be provided in a single string format with the signature <controllerPath>/controlPath
.
action.addBinding(new InputSingleBinding<bool>()
.setPath('<gamepad/*/{/sony/}>/crossButton'))
Path signatures for native Controllers are:
- The
class/type
for the Pointers (native types aremouse
,touch
,pen
e.g.pointer/mouse
) - The
class
for the Keyboard (e.g.keyboard
) - The
class/mapping/model
for the Gamepads (native mappings arestandard
andnonstandard
, while native models have the signaturevendor-model
in the form of an ID or string (for Microsoft, Sony, and Nintendo controllers) e.g.gamepad/standard/microsoft-xinput
,gamepad/standard/046d-c260
) - The
class/type
for the XR Viewers (native types arehead
,screen
e.g.xr-viewer/head
) - The
handedness/class/mapping/model
for the XR Gamepads (native mappings are based on theXRInputSource
generic profiles, while the native models are based on the model profiles e.g.left/xr-gamepad/generic-trigger-squeeze-thumbstick/meta-quest-touch-plus
) - The
handedness/class
for the XR Hands (e.g.left/xr-hand
) - The
class
for the Sensors (e.g.orientation-sensor
)
You must always specify at least the class.
Extra Binding Setup Tips
You can add Control Activators for individual Controls and, in the case of Composite Bindings, an additional Composite Control Activator for all currently active Controls.
// The binding will only listen for the Bottom Face Control of the Player One Controller
const binding = new InputSingleBinding<boolean>()
.setPath('gamepad', 'bottomFace')
// Set the Control Activator for the path
.setControlActivator('controller', playerOneController);
You can also set a Converter that returns a boolean
as a Control Activator for Single Input Bindings, or as Composite Control Activator for Composite Input Bindings:
// Despite dealing with State Controls, the Binding will only be activated when the tips of the index and the thumb are closer than 1cm
// Returns a boolean
const binding = new InputCompositeBinding<[Vector3, Vector3], boolean>()
// Set the same object as Converter and Composite Control Activator
.setConverterAsControlActivator('lessOrEqualDistance', 0.01)
.setPath(0, 'right/xr-hand', 'indexTip/position')
.setPath(1, 'right/xr-hand', 'thumbTip/position');
or, if you want still be able to get the positions as values from the Action:
// Despite dealing with State Controls, the binding will only be activated when the tips of the index and the thumb are closer than 1cm
// Returns a Tuple containing the finger tips positions
const binding = new InputCompositeBinding<[Vector3, Vector3]>()
.setPath(0, 'right/xr-hand', 'indexTip/position')
.setPath(1, 'right/xr-hand', 'thumbTip/position')
.setCompositeControlActivator('lessOrEqualDistance', 0.01);
Rebind an Action
You can rebind an Action by accessing the corresponding Binding from it and changing its Control path by overriding the current one.
Remember that if the reference of the Binding is not saved, you need to retrieve it through the getBinding
function:
// When getting a Binding you must specifiy its type
// Get it by predicate
action.getBinding<InputSingleBinding<boolean>>(
(binding) => binding.path?.controllerPath[0] === 'gamepad')!
.setPath('gamepad', 'bottomFace');
// or by name
action.getBinding<InputSingleBinding<boolean>>('my-gamepad-binding')!
.setPath('gamepad', 'bottomFace');
You can also listen for user inputs when changing a Control path:
if (gamepad.getActivatedButtons(buttons).length) {
binding.setPath('gamepad', buttons[0].getPath());
}
For more important changes just remove the old Binding and add a new one:
moveAction.removeBinding(binding);
Don't forget that if you need to adjust specific parameters of a Modifier like a DeadZone
, you can cache it before assigning it and make changes afterward.
Get a value from an Action
If you prefer a polling based approach instead of a event based one, you can get the value of the Action in a particular state with
if(moveAction.state === 'performing') {
player.move(
action.readValue().x * deltaTime,
action.readValue().y * deltaTime);
}
Remember that regardless of the approach you use, the values of the actions are always readonly.
Disable or Pause an Action
Actions and Action Groups can be enabled/disabled or paused/resumed, affecting the hierarchy.
disable
: Controls are no more automatically bound when a Controller connects to the system. This is useful when there are many actions that should not be used for a while (reactivating them creates minimal overhead)pause
: The return value is not updated, and related events are not raised. This is useful when actions need to be disabled for short intervals.
baseActions.disable();
baseActions.enable();
baseActions.pause();
baseActions.resume();
Create Custom Converters, Modifiers, Triggers and Control Activators
You can create custom Converters, Modifiers, Triggers, and Control Activators by implementing the respective interfaces: InputConverter
, InputModifier
, InputTrigger
, InputControlActivator
or InputCompositeControlActivator
.
Depending on the main method signature types, these components can be interchangeable.
Each of them can also be provided as an arrow function for faster custom implementations, as mentioned in the corresponding add/set/replace function signatures.
binding.setConverter((value) => value > 0.5); // Control type to Action type
binding.addModifier((value) => value * value); // Same Control and Action type
binding.setTrigger((value) => value > 0.5); // Control type to boolean
binding.setControlActivator((control) => control.readValue() > 0.5); // Control to boolean
binding.setCompositeControlActivator((controls) => controls[0].readValue() + controls[1].readValue() > 1); // Array of Controls to boolean
Create Custom Controller
To create a custom Controller, you just need to extend the InputController
class, have a constructor with no parameter, and set it up through the init
function to take advantage of the pooling capabilities of the system.
Note that if you want to create a Controller of a particular category, you should derive your Controller from the base Controller of that category (for example, derive from GamepadController
if you want to create a new type of gamepad).
Create Custom Controls
The same applies to Controls. While there are already many versatile Controls available, you can create your own by implementing the InputControl
interface or by extending the BaseInputControl
class.
More To Come
The actual documentation will be released in the future, and this is just a basic overview of the system's usage. Keep in mind that this is still a beta, and there may be substantial changes in the future.
Changelog
0.0.3
- Added self type definition while building Bindings (check it out here).
- Renamed the main method in Converter, Modifier and Trigger from
apply
toexecute
to avoid conflicts with theFunction.prototype.apply()
method in certain function overloads. - Removed the
readonly
modifier from the native Controls generic types and type maps. - Fixed Action's inferred generic type by removing the redundant
readonly
modifier when a readonly type is given as default. Also, addressed the potential redundancy of thereadonly
modifier in thereadValue
return type. - Fixed dependencies in the package.json.
0.0.4
- Fixed the
SetConverter
method of the Bindings when a function is set as parameter. - Readme fixes.
Funding
If you find this tool useful and plan to use it in your projects, please consider supporting its development by making a donation here. A contribution is really appreciated!