Source: components/Controller.js

import {
    Vector3,
    Euler,
    Raycaster,
} from 'three';

import { GRAVITY } from "../World.js";

const _euler = new Euler(0, 0, 0, 'YXZ');
const _PI_2 = Math.PI / 2;


/**
 * Helper class to lock the pressed keys, that you dont have to check it every frame.
 */
class KeyBoardWatcher {
    /**
     * @param {Element} domElement Where to listen for key events. Defaults to document.body.
     */
    constructor(domElement = document.body) {
        this.domElement = domElement;

        this.moveForward = false;
        this.moveBackward = false;
        this.moveLeft = false;
        this.moveRight = false;
        this.jump = false;
        this.sprint = false;

        // Key is pressed
        this.domElement.addEventListener("keydown", (ev) => {
            switch (ev.code) {

                case 'ArrowUp':
                case 'KeyW':
                    this.moveForward = true;
                    break;

                case 'ArrowLeft':
                case 'KeyA':
                    this.moveLeft = true;
                    break;

                case 'ArrowDown':
                case 'KeyS':
                    this.moveBackward = true;
                    break;

                case 'ArrowRight':
                case 'KeyD':
                    this.moveRight = true;
                    break;

                case 'Space':
                    this.jump = true;
                    break;

                case 'ShiftLeft':
                    this.sprint = true;
                    break;
            }
        });

        // Key is released
        this.domElement.addEventListener("keyup", (ev) => {
            switch (ev.code) {

                case 'ArrowUp':
                case 'KeyW':
                    this.moveForward = false;
                    break;

                case 'ArrowLeft':
                case 'KeyA':
                    this.moveLeft = false;
                    break;

                case 'ArrowDown':
                case 'KeyS':
                    this.moveBackward = false;
                    break;

                case 'ArrowRight':
                case 'KeyD':
                    this.moveRight = false;
                    break;

                case 'Space':
                    this.jump = false;
                    break;

                case 'ShiftLeft':
                    this.sprint = false;
                    break;
            }
        });

    }
}

/**
 * Helper class to control the player / camera.
 */
class Controller {
    #camera;

    /**
     * Controller to manage these Camera.
     * @param {THREE.Camera} camera 
     * @param {number} height Min. disctance from the ground. Default to 5
     * @param {number} weight Weight for calculation of the forces. Default to 2
     * @param {number} moveSpeed Speed of the WASD-Movement. Default to 0.5
     * @param {number} sensitivity How sensitiv the mouse movement to the camera is. Default to 0.005
     */
    constructor(camera, height = 5, weight = 2, moveSpeed = 0.5, sensitivity = 0.005) {
        this.#camera = camera;
        this.height = height;
        this.weight = weight;
        this.moveSpeed = moveSpeed;
        this.sensitivity = sensitivity;
        this.objectsForRaycastCollision = [];
        this.objectsForAABBCollision = [];
        this.canJump = true;

        this.acceleration = new Vector3(0, 0, 0);
        this.velocity = new Vector3(0, 0, 0);
        this.location = this.#camera.position.clone();

        this.keyBoardWatcher = new KeyBoardWatcher();
        this.isLocked = false;

        this.groundRaycaster = new Raycaster(this.location, new Vector3(1, 0, 0), 0, this.height);
        this.yRaycaster = new Raycaster(this.location, new Vector3(0, -1, 0), 0, this.height);

        document.addEventListener('mousemove', (ev) => {
            if (this.isLocked === false) {
                return;
            }
            this.mouseMove(ev);
        });

        document.addEventListener('pointerlockchange', (ev) => {
            this.lockChanged();
        }, false);

    }

    /**
     * Add the object to not pass through these objects with the camera.
     * @param {THREE.Object3D} obj
     */
    addObjectForCollision(obj) {
        const append = (item) => {
            if (this.objectsForRaycastCollision.indexOf(item) == -1) {
                this.objectsForRaycastCollision.push(item);
            }
            else {
                console.error("Object already in the array", item);
            }
        };

        if (obj.isBox3) {
            this.objectsForAABBCollision.push(obj);
        }
        else if (Array.isArray(obj)) {
            obj.forEach(item => {
                append(item);
            });
        }
        else {
            append(obj);
        }

    }

    /**
     * Set a new location with rotation for the camera
     * @param {THREE.Vector3} newPosition 
     * @param {THREE.Quaternion} quaternion 
     */
    setFixedPosition(newPosition, quaternion) {
        this.location.copy(newPosition);
        this.#camera.quaternion.copy(quaternion);
    }

    /**
     * Remove one object to not do object collision with it anymore.
     * @param {THREE.Mesh} obj 
     */
    removeObjectForCollision(obj) {
        this.objectsForRaycastCollision.splice(this.objectsForRaycastCollision.indexOf(obj), 1);
    }


    /**
     * Add a force to the movement with weight of the object
     * @param {THREE.Vector3} force Some Forces
     */
    applyForce(force) {
        const vector = force.clone();
        vector.divideScalar(this.weight);
        this.acceleration.add(vector);
    }

    /**
     * Need to be called each frame to update the position of the camera.
     */
    update() {
        if (this.canJump && this.keyBoardWatcher.jump) {
            this.applyForce(new Vector3(0, 1.5, 0));
            this.canJump = false;
            setTimeout(() => { this.canJump = true; }, 1000);
        }

        this.applyForce(GRAVITY);

        this.velocity.add(this.acceleration);
        this.acceleration.multiplyScalar(0);

        this.kameraMove();

        this.checkYCollisions();

        this.location.add(this.velocity);

        this.#camera.position.x = this.location.x;
        this.#camera.position.y = this.location.y;
        this.#camera.position.z = this.location.z;

        this.checkOutOfWorld();

    }


    /**
     * Call this function if the Mouse is moved to update the camera.
     * @param {MouseEvent} ev Fired Event
     */
    mouseMove(ev) {
        // Range is 0 to Math.PI radians
        this.minPolarAngle = 0; // radians
        this.maxPolarAngle = Math.PI; // radians

        this.pointerSpeed = 1.0;

        const scope = this;


        const movementX = ev.movementX;
        const movementY = ev.movementY;

        _euler.setFromQuaternion(this.#camera.quaternion);

        _euler.y -= movementX * this.sensitivity;
        _euler.x -= movementY * this.sensitivity;

        _euler.x = Math.max(_PI_2 - scope.maxPolarAngle, Math.min(_PI_2 - scope.minPolarAngle, _euler.x));

        this.#camera.quaternion.setFromEuler(_euler);

    }

    /**
     * Function that update the position of the camera if you pressed one of the movement keys.
     */
    kameraMove() {
        const direction = new Vector3(0, 0, 0);
        const movement = new Vector3(0, 0, 0);

        direction.z = Number(this.keyBoardWatcher.moveForward) - Number(this.keyBoardWatcher.moveBackward);
        direction.x = Number(this.keyBoardWatcher.moveRight) - Number(this.keyBoardWatcher.moveLeft);
        direction.normalize(); // this ensures consistent movements in all directions

        if (this.keyBoardWatcher.moveForward || this.keyBoardWatcher.moveBackward) {
            movement.z -= direction.z * this.moveSpeed;
        }
        if (this.keyBoardWatcher.moveLeft || this.keyBoardWatcher.moveRight) {
            movement.x -= direction.x * this.moveSpeed;
        }

        if (this.keyBoardWatcher.sprint) {
            movement.z = movement.z * 1.5;
            movement.x = movement.x * 1.5;
        }

        this.moveRight(- movement.x);
        this.moveForward(- movement.z);
    }

    /**
     * Move the camera to the right/left with the `distance`
     * @param {number} distance 
     */
    moveRight(distance) {
        const vector = new Vector3(0, 0, 0);

        vector.setFromMatrixColumn(this.#camera.matrix, 0);
        this.location.addScaledVector(vector, distance);

        this.groundRaycaster.set(this.location.clone(), vector);
        let intersections = this.groundRaycaster.intersectObjects(this.objectsForRaycastCollision, false);

        this.objectsForAABBCollision.forEach(box => {
            if (box.containsPoint(this.location)) {
                intersections = [0];
                return;
            }
        });

        if (intersections.length > 0) {
            this.location.addScaledVector(vector, -distance);
        }
    }

    /**
     * Move the camera to the forward/back with the `distance`
     * @param {number} distance 
     */
    moveForward(distance) {
        const vector = new Vector3(0, 0, 0);

        vector.setFromMatrixColumn(this.#camera.matrix, 0);
        vector.crossVectors(this.#camera.up, vector);

        this.location.addScaledVector(vector, distance);

        this.groundRaycaster.set(this.location.clone(), vector);
        let intersections = this.groundRaycaster.intersectObjects(this.objectsForRaycastCollision, false);

        this.objectsForAABBCollision.forEach(box => {
            if (box.containsPoint(this.location)) {
                intersections = [0];
                return;
            }
        });

        if (intersections.length > 0) {
            this.location.addScaledVector(vector, -distance);
        }
    };

    /**
     * Lock the Mouse-Pointer
     * @see https://developer.mozilla.org/en-US/docs/Web/API/Element/requestPointerLock
     */
    lock() {
        document.body.requestPointerLock();
    }

    /**
     * Unlock the Mouse-Pointer
     * @see https://developer.mozilla.org/en-US/docs/Web/API/Document/exitPointerLock
     */
    unlock() {
        document.exitPointerLock();

    }

    /**
     * Toggle the information if the Mouse is locked
     */
    lockChanged() {
        this.isLocked = !this.isLocked;
        console.log("Lock changed");
    }

    /**
     * Check the Colissions with the added objects from `addObjectForCollision(obj)`
     */
    checkYCollisions() {
        //#region Check bottom
        this.yRaycaster.set(this.location, new Vector3(0, -1, 0));
        const intersectionsBottom = this.yRaycaster.intersectObjects(this.objectsForRaycastCollision, false);

        if (intersectionsBottom.length > 0) {
            const distance = intersectionsBottom[0].distance;
            this.location.y += this.height - distance;
            this.velocity.y = Math.max(0, this.velocity.y);
        }
        //#endregion

        //#region Check top
        this.yRaycaster.set(this.location, new Vector3(0, 1, 0));
        const intersectionsTop = this.yRaycaster.intersectObjects(this.objectsForRaycastCollision, false);

        if (intersectionsTop.length > 0) {
            this.velocity.y = 0;
        }
        //#endregion
    }

    /**
     * Reset the Camera to 10, 18, -30 if the player is fallen out of the world
     */
    checkOutOfWorld() {
        if (this.location.y < - 100) {
            this.location = new Vector3(10, 18, -30);
            this.velocity.y = 0;
        }
    }
}

export { Controller };