src_engineInput.js

/**
 * 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;
}