/**
* LittleJS Input System
* - Tracks keyboard down, pressed, and released
* - Tracks mouse buttons, position, and wheel
* - Tracks multiple analog gamepads
* - Touch input is handled as mouse input
* - Virtual gamepad for touch devices
* @namespace Input
*/
'use strict';
/** Mouse pos in world space
* @type {Vector2}
* @memberof Input */
let mousePos = vec2();
/** Mouse pos in screen space
* @type {Vector2}
* @memberof Input */
let mousePosScreen = vec2();
/** Mouse movement delta in world space
* @type {Vector2}
* @memberof Input */
let mouseDelta = vec2();
/** Mouse movement delta in screen space
* @type {Vector2}
* @memberof Input */
let mouseDeltaScreen = vec2();
/** Mouse wheel delta this frame
* @type {number}
* @memberof Input */
let mouseWheel = 0;
/** True if mouse was inside the document window, set to false when mouse leaves
* @type {boolean}
* @memberof Input */
let mouseInWindow = true;
/** Returns true if user is using gamepad (has more recently pressed a gamepad button)
* @type {boolean}
* @memberof Input */
let isUsingGamepad = false;
/** Prevents input continuing to the default browser handling (true by default)
* @type {boolean}
* @memberof Input */
let inputPreventDefault = true;
/** Primary gamepad index, automatically set to first gamepad with input
* @type {number}
* @memberof Input */
let gamepadPrimary = 0;
/** Prevents input continuing to the default browser handling
* This is useful to disable for html menus so the browser can handle input normally
* @param {boolean} preventDefault
* @memberof Input */
function setInputPreventDefault(preventDefault) { inputPreventDefault = preventDefault; }
/** Clears an input key state
* @param {string|number} key
* @param {number} [device]
* @param {boolean} [clearDown=true]
* @param {boolean} [clearPressed=true]
* @param {boolean} [clearReleased=true]
* @memberof Input */
function inputClearKey(key, device=0, clearDown=true, clearPressed=true, clearReleased=true)
{
if (!inputData[device])
return;
inputData[device][key] &= ~((clearDown?1:0)|(clearPressed?2:0)|(clearReleased?4:0));
}
/** Clears all input
* @memberof Input */
function inputClear()
{
inputData.length = 0;
inputData[0] = [];
touchGamepadButtons.length = 0;
touchGamepadSticks.length = 0;
gamepadStickData.length = 0;
gamepadDpadData.length = 0;
}
///////////////////////////////////////////////////////////////////////////////
/** Returns true if device key is down
* @param {string|number} key
* @param {number} [device]
* @return {boolean}
* @memberof Input */
function keyIsDown(key, device=0)
{
ASSERT(isString(key), 'key must be a number or string');
ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
return !!(inputData[device]?.[key] & 1);
}
/** Returns true if device key was pressed this frame
* @param {string|number} key
* @param {number} [device]
* @return {boolean}
* @memberof Input */
function keyWasPressed(key, device=0)
{
ASSERT(isString(key), 'key must be a number or string');
ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
return !!(inputData[device]?.[key] & 2);
}
/** Returns true if device key was released this frame
* @param {string|number} key
* @param {number} [device]
* @return {boolean}
* @memberof Input */
function keyWasReleased(key, device=0)
{
ASSERT(isString(key), 'key must be a number or string');
ASSERT(device > 0 || typeof key !== 'number' || key < 3, 'use code string for keyboard');
return !!(inputData[device]?.[key] & 4);
}
/** Returns input vector from arrow keys or WASD if enabled
* @param {string} [up]
* @param {string} [down]
* @param {string} [left]
* @param {string} [right]
* @return {Vector2}
* @memberof Input */
function keyDirection(up='ArrowUp', down='ArrowDown', left='ArrowLeft', right='ArrowRight')
{
ASSERT(isString(up), 'up key must be a string');
ASSERT(isString(down), 'down key must be a string');
ASSERT(isString(left), 'left key must be a string');
ASSERT(isString(right), 'right key must be a string');
const k = (key)=> keyIsDown(key) ? 1 : 0;
return vec2(k(right) - k(left), k(up) - k(down));
}
/** Returns true if mouse button is down
* @function
* @param {number} button
* @return {boolean}
* @memberof Input */
function mouseIsDown(button)
{
ASSERT(isNumber(button), 'mouse button must be a number');
return keyIsDown(button);
}
/** Returns true if mouse button was pressed
* @function
* @param {number} button
* @return {boolean}
* @memberof Input */
function mouseWasPressed(button)
{
ASSERT(isNumber(button), 'mouse button must be a number');
return keyWasPressed(button);
}
/** Returns true if mouse button was released
* @function
* @param {number} button
* @return {boolean}
* @memberof Input */
function mouseWasReleased(button)
{
ASSERT(isNumber(button), 'mouse button must be a number');
return keyWasReleased(button);
}
/** Returns true if gamepad button is down
* @param {number} button
* @param {number} [gamepad]
* @return {boolean}
* @memberof Input */
function gamepadIsDown(button, gamepad=gamepadPrimary)
{
ASSERT(isNumber(button), 'button must be a number');
ASSERT(isNumber(gamepad), 'gamepad must be a number');
return keyIsDown(button, gamepad+1);
}
/** Returns true if gamepad button was pressed
* @param {number} button
* @param {number} [gamepad]
* @return {boolean}
* @memberof Input */
function gamepadWasPressed(button, gamepad=gamepadPrimary)
{
ASSERT(isNumber(button), 'button must be a number');
ASSERT(isNumber(gamepad), 'gamepad must be a number');
return keyWasPressed(button, gamepad+1);
}
/** Returns true if gamepad button was released
* @param {number} button
* @param {number} [gamepad]
* @return {boolean}
* @memberof Input */
function gamepadWasReleased(button, gamepad=gamepadPrimary)
{
ASSERT(isNumber(button), 'button must be a number');
ASSERT(isNumber(gamepad), 'gamepad must be a number');
return keyWasReleased(button, gamepad+1);
}
/** Returns gamepad stick value
* @param {number} stick
* @param {number} [gamepad]
* @return {Vector2}
* @memberof Input */
function gamepadStick(stick, gamepad=gamepadPrimary)
{
ASSERT(isNumber(stick), 'stick must be a number');
ASSERT(isNumber(gamepad), 'gamepad must be a number');
return gamepadStickData[gamepad]?.[stick] ?? vec2();
}
/** Returns gamepad dpad value
* @param {number} [gamepad]
* @return {Vector2}
* @memberof Input */
function gamepadDpad(gamepad=gamepadPrimary)
{
ASSERT(isNumber(gamepad), 'gamepad must be a number');
return gamepadDpadData[gamepad] ?? vec2();
}
/** Returns true if passed in gamepad is connected
* @param {number} [gamepad]
* @return {boolean}
* @memberof Input */
function gamepadConnected(gamepad=gamepadPrimary)
{
ASSERT(isNumber(gamepad), 'gamepad must be a number');
return !!inputData[gamepad+1];
}
/** Returns how many control sticks the passed in gamepad has
* @param {number} [gamepad]
* @return {number}
* @memberof Input */
function gamepadStickCount(gamepad=gamepadPrimary)
{
ASSERT(isNumber(gamepad), 'gamepad must be a number');
return gamepadStickData[gamepad]?.length ?? 0;
}
/** True if a touch device has been detected
* @memberof Input */
const isTouchDevice = !headlessMode && window.ontouchstart !== undefined;
///////////////////////////////////////////////////////////////////////////////
/** Pulse the vibration hardware if it exists
* @param {number|Array} [pattern] - single value in ms or vibration interval array
* @memberof Input */
function vibrate(pattern=100)
{
ASSERT(isNumber(pattern) || isArray(pattern), 'pattern must be a number or array');
vibrateEnable && !headlessMode && navigator && navigator.vibrate && navigator.vibrate(pattern);
}
/** Cancel any ongoing vibration
* @memberof Input */
function vibrateStop() { vibrate(0); }
///////////////////////////////////////////////////////////////////////////////
// Pointer Lock
/** Request to lock the pointer, does not work on touch devices
* @memberof Input */
function pointerLockRequest()
{ !isTouchDevice && mainCanvas.requestPointerLock?.(); }
/** Request to unlock the pointer
* @memberof Input */
function pointerLockExit()
{ document.exitPointerLock?.(); }
/** Check if pointer is locked (true if locked)
* @return {boolean}
* @memberof Input */
function pointerLockIsActive()
{ return document.pointerLockElement === mainCanvas; }
///////////////////////////////////////////////////////////////////////////////
// Input variables used by engine
// input uses bit field for each key: 1=isDown, 2=wasPressed, 4=wasReleased
// mouse and keyboard stored in device 0, gamepads stored in devices > 0
const inputData = [[]];
// gamepad internal variables
const gamepadStickData = [], gamepadDpadData = [], gamepadHadInput = [];
// touch gamepad internal variables
const touchGamepadTimer = new Timer, touchGamepadButtons = [], touchGamepadSticks = [];
///////////////////////////////////////////////////////////////////////////////
// Input system functions used by engine
function inputInit()
{
if (headlessMode) return;
// add event listeners
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
document.addEventListener('mousedown', onMouseDown);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseleave', onMouseLeave);
document.addEventListener('wheel', onMouseWheel);
document.addEventListener('contextmenu', onContextMenu);
document.addEventListener('blur', onBlur);
// init touch input
if (isTouchDevice && touchInputEnable)
touchInputInit();
function onKeyDown(e)
{
if (!e.repeat)
{
isUsingGamepad = false;
inputData[0][e.code] = 3;
if (inputWASDEmulateDirection)
inputData[0][remapKey(e.code)] = 3;
}
// prevent arrow key from moving the page
const preventDefaultKeys =
[
'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', // scrolling
'Space', // page down scroll
'Tab', // focus navigation
'Backspace', // browser back
];
if (preventDefaultKeys.includes(e.code))
if (inputPreventDefault && document.hasFocus() && e.cancelable)
e.preventDefault();
}
function onKeyUp(e)
{
inputData[0][e.code] = (inputData[0][e.code]&2) | 4;
if (inputWASDEmulateDirection)
inputData[0][remapKey(e.code)] = 4;
}
function remapKey(k)
{
// handle remapping wasd keys to directions
return inputWASDEmulateDirection ?
k === 'KeyW' ? 'ArrowUp' :
k === 'KeyS' ? 'ArrowDown' :
k === 'KeyA' ? 'ArrowLeft' :
k === 'KeyD' ? 'ArrowRight' : k : k;
}
function onMouseDown(e)
{
if (isTouchDevice && touchInputEnable)
return;
// fix stalled audio requiring user interaction
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
audioContext.resume();
isUsingGamepad = false;
inputData[0][e.button] = 3;
const mousePosScreenLast = mousePosScreen;
mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
if (inputPreventDefault && document.hasFocus() && e.cancelable)
e.preventDefault();
}
function onMouseUp(e)
{
if (isTouchDevice && touchInputEnable)
return;
inputData[0][e.button] = (inputData[0][e.button]&2) | 4;
}
function onMouseMove(e)
{
mouseInWindow = true;
const mousePosScreenLast = mousePosScreen;
mousePosScreen = mouseEventToScreen(vec2(e.x,e.y));
// when pointer is locked use movementX/Y for delta
const movement = pointerLockIsActive() ?
vec2(e.movementX, e.movementY) :
mousePosScreen.subtract(mousePosScreenLast);
mouseDeltaScreen = mouseDeltaScreen.add(movement);
}
function onMouseLeave() { mouseInWindow = false; } // mouse moved off window
function onMouseWheel(e) { mouseWheel = e.ctrlKey ? 0 : sign(e.deltaY); }
function onContextMenu(e) { e.preventDefault(); } // prevent right click menu
function onBlur() { inputClear(); } // reset input when focus is lost
// enable touch input mouse passthrough
function touchInputInit()
{
// add non passive touch event listeners
document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
// handle all touch events the same way
let wasTouching;
function handleTouch(e)
{
if (!touchInputEnable)
return;
// route touch to gamepad
if (touchGamepadEnable)
handleTouchGamepad(e);
// fix stalled audio requiring user interaction
if (soundEnable && !headlessMode && audioContext && !audioIsRunning())
audioContext.resume();
// check if touching and pass to mouse events
const touching = e.touches.length;
const button = 0; // all touches are left mouse button
if (touching)
{
// set event pos and pass it along
const pos = vec2(e.touches[0].clientX, e.touches[0].clientY);
const mousePosScreenLast = mousePosScreen;
mousePosScreen = mouseEventToScreen(pos);
if (wasTouching)
{
mouseDeltaScreen = mouseDeltaScreen.add(mousePosScreen.subtract(mousePosScreenLast));
isUsingGamepad = touchGamepadEnable;
}
else
inputData[0][button] = 3;
}
else if (wasTouching)
inputData[0][button] = inputData[0][button] & 2 | 4;
// set was touching
wasTouching = touching;
// prevent default handling like copy, magnifier lens, and scrolling
if (inputPreventDefault && document.hasFocus() && e.cancelable)
e.preventDefault();
// must return true so the document will get focus
return true;
}
// special handling for virtual gamepad mode
function handleTouchGamepad(e)
{
// clear touch gamepad input
touchGamepadSticks.length = 0;
touchGamepadSticks[0] = vec2();
touchGamepadSticks[1] = vec2();
touchGamepadButtons.length = 0;
isUsingGamepad = true;
const touching = e.touches.length;
if (touching)
{
touchGamepadTimer.set();
if (touchGamepadCenterButton && !wasTouching && paused)
{
// touch anywhere to press start when paused
touchGamepadButtons[9] = 1;
return;
}
}
// don't process touch gamepad if paused
if (paused)
return;
// get center of left and right sides
const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
const buttonCenter = touchGamepadButtonCenter();
const startCenter = mainCanvasSize.scale(.5);
// check each touch point
for (const touch of e.touches)
{
const touchPos = mouseEventToScreen(vec2(touch.clientX, touch.clientY));
if (stickCenter.distance(touchPos) < touchGamepadSize)
{
// virtual analog stick
const delta = touchPos.subtract(stickCenter);
touchGamepadSticks[0] = delta.scale(2/touchGamepadSize).clampLength();
}
else if (buttonCenter.distance(touchPos) < touchGamepadSize)
{
if (touchGamepadButtonCount === 1)
{
// virtual right analog stick
const delta = touchPos.subtract(buttonCenter);
touchGamepadSticks[1] = delta.scale(2/touchGamepadSize).clampLength();
}
// virtual face buttons
let button = buttonCenter.subtract(touchPos).direction();
button = mod(button+2, 4);
if (touchGamepadButtonCount === 1)
button = 0;
else if (touchGamepadButtonCount === 2)
{
const delta = buttonCenter.subtract(touchPos);
button = -delta.x < delta.y ? 1 : 0;
}
// fix button locations (swap 2 and 3 to match gamepad layout)
button = button === 3 ? 2 : button === 2 ? 3 : button;
if (button < touchGamepadButtonCount)
touchGamepadButtons[button] = 1;
}
else if (touchGamepadCenterButton &&
startCenter.distance(touchPos) < touchGamepadSize)
{
// virtual start button in center
touchGamepadButtons[9] = 1;
}
}
}
}
// convert a mouse or touch event position to screen space
function mouseEventToScreen(mousePos)
{
const rect = mainCanvas.getBoundingClientRect();
const px = percent(mousePos.x, rect.left, rect.right);
const py = percent(mousePos.y, rect.top, rect.bottom);
return vec2(px*mainCanvas.width, py*mainCanvas.height);
}
}
function inputUpdate()
{
if (headlessMode) return;
// clear input when lost focus (prevent stuck keys)
if (!(touchInputEnable && isTouchDevice) && !document.hasFocus())
inputClear();
// update mouse world space position and delta
mousePos = screenToWorld(mousePosScreen);
mouseDelta = screenToWorldDelta(mouseDeltaScreen);
// update gamepads if enabled
gamepadsUpdate();
// gamepads are updated by engine every frame automatically
function gamepadsUpdate()
{
const applyDeadZones = (v)=>
{
const min=.3, max=.8;
const deadZone = (v)=>
v > min ? percent(v, min, max) :
v < -min ? -percent(-v, min, max) : 0;
return vec2(deadZone(v.x), deadZone(-v.y)).clampLength();
}
// update touch gamepad if enabled
if (touchGamepadEnable && isTouchDevice)
{
if (!touchGamepadTimer.isSet())
return;
// read virtual analog stick
gamepadPrimary = 0; // touch gamepad uses index 0
const sticks = gamepadStickData[0] ?? (gamepadStickData[0] = []);
const dpad = gamepadDpadData[0] ?? (gamepadDpadData[0] = vec2());
sticks[0] = vec2();
dpad.set();
const leftTouchStick = touchGamepadSticks[0] ?? vec2();
if (touchGamepadAnalog)
sticks[0] = applyDeadZones(leftTouchStick);
else if (leftTouchStick.lengthSquared() > .3)
{
// convert to 8 way dpad
const x = clamp(round(leftTouchStick.x), -1, 1);
const y = clamp(round(leftTouchStick.y), -1, 1);
dpad.set(x, -y);
sticks[0] = dpad.clampLength(); // clamp to circle
}
if (touchGamepadButtonCount === 1)
{
const rightTouchStick = touchGamepadSticks[1] ?? vec2();
sticks[1] = applyDeadZones(rightTouchStick);
}
// read virtual gamepad buttons
const data = inputData[1] ?? (inputData[1] = []);
for (let i=10; i--;)
{
const wasDown = gamepadIsDown(i,0);
data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
}
// disable normal gamepads when touch gamepad is active
return;
}
// return if gamepads are disabled or not supported
if (!gamepadsEnable || !navigator || !navigator.getGamepads)
return;
// only poll gamepads when focused or in debug mode
if (!debug && !document.hasFocus())
return;
// poll gamepads
const maxGamepads = 8;
const gamepads = navigator.getGamepads();
const gamepadCount = min(maxGamepads, gamepads.length)
for (let i=0; i<gamepadCount; ++i)
{
// get or create gamepad data
const gamepad = gamepads[i];
if (!gamepad)
{
// clear gamepad data if not connected
inputData[i+1] = undefined;
gamepadStickData[i] = undefined;
gamepadDpadData[i] = undefined;
gamepadHadInput[i] = undefined;
continue;
}
const data = inputData[i+1] ?? (inputData[i+1] = []);
const sticks = gamepadStickData[i] ?? (gamepadStickData[i] = []);
const dpad = gamepadDpadData[i] ?? (gamepadDpadData[i] = vec2());
// read analog sticks
for (let j = 0; j < gamepad.axes.length-1; j+=2)
sticks[j>>1] = applyDeadZones(vec2(gamepad.axes[j],gamepad.axes[j+1]));
// read buttons
let hadInput = false;
for (let j = gamepad.buttons.length; j--;)
{
const button = gamepad.buttons[j];
const wasDown = gamepadIsDown(j,i);
data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
// check for any input on this gamepad, analog must be full press
if (button.pressed)
if (!button.value || button.value > .9)
hadInput = true;
}
// set new primary gamepad if current is not connected
if (hadInput)
{
gamepadHadInput[i] = true;
if (!gamepadHadInput[gamepadPrimary])
gamepadPrimary = i;
isUsingGamepad ||= (gamepadPrimary === i);
}
if (gamepad.mapping === 'standard')
{
// get dpad buttons (standard mapping)
dpad.set(
(gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
(gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
}
else if (gamepad.axes && gamepad.axes.length >= 2)
{
// digital style dpad from axes
const x = clamp(round(gamepad.axes[0]), -1, 1);
const y = clamp(round(gamepad.axes[1]), -1, 1);
dpad.set(x, -y);
}
// copy dpad to left analog stick when pressed
if (gamepadDirectionEmulateStick && !dpad.isZero())
sticks[0] = dpad.clampLength();
}
// disable touch gamepad if using real gamepad
touchGamepadEnable && isUsingGamepad && touchGamepadTimer.unset();
}
}
function inputUpdatePost()
{
if (headlessMode) return;
// clear input to prepare for next frame
for (const deviceInputData of inputData)
for (const i in deviceInputData)
deviceInputData[i] &= 1;
mouseWheel = 0;
mouseDelta = vec2();
mouseDeltaScreen = vec2();
}
function inputRender()
{
touchGamepadRender();
function touchGamepadRender()
{
if (!touchInputEnable || !isTouchDevice || headlessMode) return;
if (!touchGamepadEnable || !touchGamepadTimer.isSet())
return;
// fade off when not touching or paused
const alpha = percent(touchGamepadTimer.get(), 4, 3);
if (!alpha || paused)
return;
// setup the canvas
const context = overlayContext;
context.save();
context.globalAlpha = alpha*touchGamepadAlpha;
context.strokeStyle = '#fff';
context.lineWidth = 3;
// draw left analog stick
const leftTouchStick = touchGamepadSticks[0] ?? vec2();
context.fillStyle = leftTouchStick.lengthSquared() > 0 ? '#fff' : '#000';
context.beginPath();
const stickCenter = vec2(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
if (touchGamepadAnalog)
{
// draw circle shaped gamepad
context.arc(stickCenter.x, stickCenter.y, touchGamepadSize/2, 0, 9);
}
else
{
// draw cross shaped gamepad
for (let i=10; --i;)
{
const angle = i*PI/4;
context.arc(stickCenter.x, stickCenter.y,touchGamepadSize*.6, angle + PI/8, angle + PI/8);
i%2 && context.arc(stickCenter.x, stickCenter.y, touchGamepadSize*.33, angle, angle);
}
}
context.fill();
context.stroke();
// draw right face buttons
{
const buttonCenter = touchGamepadButtonCenter();
const buttonSize = touchGamepadButtonCount > 1 ?
touchGamepadSize/4 : touchGamepadSize/2;
for (let i=0; i<touchGamepadButtonCount; i++)
{
const j = mod(i-1, 4);
let button = touchGamepadButtonCount > 2 ?
j : min(j, touchGamepadButtonCount-1);
// fix button locations (swap 2 and 3 to match gamepad layout)
button = button === 3 ? 2 : button === 2 ? 3 : button;
const pos = touchGamepadButtonCount < 2 ? buttonCenter :
buttonCenter.add(vec2().setDirection(j, touchGamepadSize/2));
context.fillStyle = touchGamepadButtons[button] ? '#fff' : '#000';
context.beginPath();
context.arc(pos.x, pos.y, buttonSize, 0,9);
context.fill();
context.stroke();
}
}
// set canvas back to normal
context.restore();
}
}
// center position for right tocuh pad face buttons
function touchGamepadButtonCenter()
{
const center = mainCanvasSize.subtract(vec2(touchGamepadSize));
if (touchGamepadButtonCount === 2)
center.x += touchGamepadSize/2;
return center;
}