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.
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.
(The Vector
types, besides having elements indexed by letters, can also be accessed by numbers, making them ArrayLike<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
- Keyboard
- Gamepad
- XR
- 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, is started
, is performing
or is ended
, 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);
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:
inputManager.pointer.mouse.native.enable(target);
inputManager.pointer.pen.native.enable(target);
inputManager.pointer.touch.native.enable(target);
inputManager.keyboard.native.enable()
inputManager.gamepad.native.enable()
inputManager.xr.gamepad.native.enable();
inputManager.xr.hand.native.enable();
inputManager.xr.viewer.head.native.enable();
inputManager.xr.viewer.screen.native.enable();
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();
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:
const baseActions = inputManager.getActionGroup('baseActions');
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');
const subActions = baseActions.addActionGroup(new InputActionGroup('subActions'));
Than, you can finally add the Action:
const moveAction = new InputAction('move', Vector2.zero);
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:
const moveGamepadBinding = new InputSingleBinding<Vector2>('gamepad-stick')
.setPath('gamepad', 'leftStick')
.addModifier('stickDeadZone', 0.2, 0.9);
const moveGamepadBinding = new InputSingleBinding('gamepad-stick')
.setPath('gamepad', 'leftStick')
.addModifier('stickDeadZone', 0.2, 0.9);
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:
const moveKeyboardBinding = new InputCompositeBinding<[boolean, boolean, boolean, boolean], Vector2>()
.setConverter('compositeVector2')
.setPath(0, 'keyboard', 'keyA')
.setPath(1, 'keyboard', 'keyD')
.setPath(2, 'keyboard', 'keyW')
.setPath(3, 'keyboard', 'keyS');
const moveKeyboardBinding = new InputCompositeBinding()
.setConverter('compositeVector2')
.setPath(0, 'keyboard', 'keyA')
.setPath(1, 'keyboard', 'keyD')
.setPath(2, 'keyboard', 'keyW')
.setPath(3, 'keyboard', 'keyS');
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:
fireAction.setTrigger('hold', 0.5);
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>()
.setPath('gamepad/standard/sony-dualshock-4', 'crossButton'))
.addBinding(new InputSingleBinding<bool>()
.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>()
.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 are mouse
, touch
, pen
e.g. pointer/mouse
) - The
class
for the Keyboard
(e.g. keyboard
) - The
class/mapping/model
for the Gamepads
(native mappings are standard
and nonstandard
, while native models have the signature vendor-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 are head
, screen
e.g. xr-viewer/head
) - The
handedness/class/mapping/model
for the XR Gamepads
(native mappings are based on the XRInputSource
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.
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.
const binding = new InputSingleBinding<boolean>()
.setPath('gamepad', 'bottomFace')
.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:
const binding = new InputCompositeBinding<[Vector3, Vector3], boolean>()
.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:
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:
action.getBinding<InputSingleBinding<boolean>>(
(binding) => binding.path?.controllerPath[0] === 'gamepad')!
.setPath('gamepad', 'bottomFace');
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);
binding.addModifier((value) => value * value);
binding.setTrigger((value) => value > 0.5);
binding.setControlActivator((control) => control.readValue() > 0.5);
binding.setCompositeControlActivator((controls) => controls[0].readValue() + controls[1].readValue() > 1);
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
to execute
to avoid conflicts with the Function.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 the readonly
modifier in the readValue
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!