utils_InputHandler.js

const Particle = require("../core/Particle");
const Vector2D = require("./Vector2D");
const PositionPivotConstraint = require("../constraints/PositionPivotConstraint");

/**
 * A utility class that provides a quick user-input handling functionality. 
 * It supports mouse interactions with world items, with default mouse-particle interactions baked in.
 * Moreover, it also has key-event listening (can be on a focusable element, or the whole window).
 * Lastly, it can handle simultaneous mouse-key press combinations.
 * 
 * Note: This will only work with an HTML canvas element in the browser.
 */
class InputHandler {
    /**
     * @param {World} world world that the handler effects
     * @param {Boolean} enableMouseInteractions whether particle will interact with mouse (true by default)
     * @constructor
     */
    constructor(world, enableMouseInteractions=true) {
        this.world = world;
        this.canvas = world.canvas;

        this.mouseFocusElement = this.canvas;
        this.keyFocusElement = window;

        this.mouseDownPosition = new Vector2D(0,0);
        this.mousePosition = new Vector2D(0,0);
        this.mouseIsDown = false;

        this.keyPress = null;

        this.selectedParticle = null;
        this.currentlySelectedParticle = null;
        this.particleConstraint = null;

        this.keyEvents = [];

        this.minSelectRadius = 20;

        this.enableMouseInteractions = enableMouseInteractions;

        this.mouseSelectRadius = 15;

        this.mouseDownFunction = () => {};
        this.mouseUpFunction = () => {};
    }

    /**
     * Gets the mouse position relative to the world canvas element.
     * @param {Event} event 
     * @param {InputHandler} handler 
     * @returns {Vector2D} the mouse position
     * @private
     */
    getMousePos(event, handler) {
        const rect = handler.canvas.getBoundingClientRect();
        return new Vector2D(event.clientX - rect.left, event.clientY - rect.top);
    }

    /**
     * Begins mouse tracking (must be called for mouse interactions)
     * @public
     */
    startMouseHandling() {
        this.mouseFocusElement.addEventListener("mousedown", (event) => {this.mousedown(event, this)});
        this.mouseFocusElement.addEventListener("mousemove", (event) => {this.mousemove(event, this)});
        window.addEventListener("mouseup", (event) => {this.mouseup(event, this)});
    }

    /**
     * Begins key tracking (must be called for key interactions)
     * @public
     */
    startKeyHandling() {
        // this is cursed as hell...
        this.keyFocusElement.addEventListener("keydown", (event)  => {this.keyDown(event, this)});   
        this.keyFocusElement.addEventListener("keyup", (event) => {this.keyUp(event, this)});
    }

    /**
     * Function for actions performed during a mousedown event
     * @param {Event} event 
     * @param {InputHandler} handler 
     * @private
     */
    mousedown(event, handler) {
        handler.mousePosition = handler.getMousePos(event, handler);
        handler.mouseDownPosition = handler.getMousePos(event, handler);
        handler.mouseIsDown = true;
        handler.findSelectedParticle(handler);

        if (handler.currentlySelectedParticle !== null && handler.enableMouseInteractions) {
            handler.createConstraint(handler);
        }

        handler.mouseDownFunction();
    }

    /**
     * Function for actions performed during a mousemove event
     * @param {Event} event 
     * @param {InputHandler} handler
     * @private 
     */
    mousemove(event, handler) {
        handler.mousePosition = handler.getMousePos(event, handler);
        if (handler.particleConstraint) {
            handler.particleConstraint.pos = handler.mousePosition;
        }
    }

    /**
     * Function for actions performed during a mouseup event
     * @param {Event} event 
     * @param {InputHandler} handler
     * @private 
     */
    mouseup(event, handler) {
        handler.mouseIsDown = false;
        handler.currentlySelectedParticle = null;
        handler.removeConstraint(handler);
        handler.mouseUpFunction();
    }

    /**
     * Finds the particle selected by mouse (either the particle clicked by mouse, or the closest one to it in a certain radius). 
     * Sets `this.selectedParticle` and `this.currentlySelectedParticle` as well.
     * @param {InputHandler} handler 
     * @private
     */
    findSelectedParticle(handler) {
        const zero = new Vector2D(0,0);
        const testParticle = new Particle(handler.mouseDownPosition, zero, 1, this.mouseSelectRadius);
        let min = Infinity;
        let minParticle = null;

        for (let particle of handler.world.particles.findNear(testParticle)) {
            const dist = particle.pos.sub(handler.mouseDownPosition).mag()
            if (dist<= particle.radius) {
                handler.selectedParticle = particle;
                handler.currentlySelectedParticle = particle;
                return;
            }else if (dist <= handler.minSelectRadius && dist < min) {
                min = dist;
                minParticle = particle;
            }   
        }

        if (minParticle !== null) {
            handler.selectedParticle = minParticle;
            handler.currentlySelectedParticle = minParticle;
        }
    }

    /**
     * Creates a constraint between the `mousePosition` and the `currentlySelectedParticle`
     * @param {InputHandler} handler 
     * @private
     */
    createConstraint(handler) {
        //let stiffness = handler.currentlySelectedParticle.mass * 50;
        //handler.particleConstraint = new ForcePivotConstraint(handler.mousePosition, handler.currentlySelectedParticle, 0, stiffness, stiffness/5);
        handler.particleConstraint = new PositionPivotConstraint(handler.mousePosition, handler.currentlySelectedParticle, 0, 
            0.3 / handler.world.iterationPerFrame / handler.world.iterationPerFrame);
        handler.world.addConstraint(handler.particleConstraint);
    }

    /**
     * Removes constraint between the `mousePosition` and the `currentlySelectedParticle`
     * @param {InputHandler} handler
     * @private 
     */
    removeConstraint(handler) {
        if (handler.particleConstraint !== null) {
            handler.world.removeConstraint(handler.particleConstraint);
            handler.particleConstraint = null;
        }
    }

    /**
     * Function for keydown events
     * @param {Event} event 
     * @param {InputHandler} handler 
     * @private
     */
    keyDown(event, handler) {
        const key = event.key;
        for (let keyInput of handler.keyEvents) {
            if (keyInput.isMouseDown) {
                if (key === keyInput.keyCode && handler.mouseIsDown) {
                    handler.keyPress = key;
                    keyInput.func();
                }
            } else {
                if (key === keyInput.keyCode) {
                    handler.keyPress = key;
                    keyInput.func();
                }
            }
        }
    }

    /**
     * Function for keyUp events
     * @param {Event} event 
     * @param {InputHandler} handler 
     * @private
     */
    keyUp(event, handler) {
        handler.keyPress = null
    }

    /**
     * Adds a `KeyInput` to the list of events that the `InputHandler` tracks
     * @param {KeyInput} keyInput 
     * @public
     */
    addKeyEvent(keyInput) {
        this.keyEvents.push(keyInput);
    }
}

/**
 * Static inner class of `InputHandler` for tracking Key input types.
 * @inner
 * @static
 */
InputHandler.KeyInput = class KeyInput {
    /**
     * @param {keyCode} keyCode activation key code
     * @param {Function} func action to be performed
     * @param {Boolean} isMouseDown determines whether activation requires mouseDown 
     * @constructor
     */
    constructor(keyCode, func, isMouseDown=false) {
        this.keyCode = keyCode || "";
        this.func = func || (() => {});
        this.isMouseDown = isMouseDown;
    }
}

module.exports = InputHandler;