/* * This file is part of BlueMap, licensed under the MIT License (MIT). * * Copyright (c) Blue (Lukas Rieger) * Copyright (c) contributors * * Permission is hereby granted, free of charge, to any person obtaining a copy * of this software and associated documentation files (the "Software"), to deal * in the Software without restriction, including without limitation the rights * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in * all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN * THE SOFTWARE. */ import {MathUtils, Vector3} from "three"; import {dispatchEvent} from "../util/Utils"; import {Map} from "../map/Map"; import {reactive} from "vue"; export class ControlsManager { /** * @param mapViewer {MapViewer} * @param camera {CombinedCamera} */ constructor(mapViewer, camera) { Object.defineProperty( this, 'isControlsManager', { value: true } ); this.data = reactive({ mapViewer: null, camera: null, controls: null, position: new Vector3(0, 0, 0), rotation: 0, angle: 0, tilt: 0, }); this.mapViewer = mapViewer; this.camera = camera; /** @type {Vector3} */ this.lastPosition = this.position.clone(); this.lastRotation = this.rotation; this.lastAngle = this.angle; this.lastDistance = this.distance; this.lastOrtho = this.ortho; this.lastTilt = this.tilt; this.lastMapUpdatePosition = null; this.lastMapUpdateDistance = null; this.averageDeltaTime = 16; this._controls = null; // start this.distance = 300; this.position.set(0, 0, 0); this.rotation = 0; this.angle = 0; this.tilt = 0; this.ortho = 0; this.updateCamera(); } /** * @param deltaTime {number} * @param map {Map} */ update(deltaTime, map) { if (deltaTime > 50) deltaTime = 50; // assume min 20 UPS this.averageDeltaTime = this.averageDeltaTime * 0.9 + deltaTime * 0.1; // average delta-time to avoid choppy controls on lag-spikes if (this._controls) this._controls.update(this.averageDeltaTime, map); this.updateCamera(); } updateCamera() { let valueChanged = this.isValueChanged(); if (valueChanged) { this.resetValueChanged(); // wrap rotation while (this.rotation >= Math.PI) this.rotation -= Math.PI * 2; while (this.rotation <= -Math.PI) this.rotation += Math.PI * 2; // prevent problems with the rotation when the angle is 0 (top-down) or distance is 0 (first-person) let rotatableAngle = this.angle; if (Math.abs(rotatableAngle) <= 0.0001) rotatableAngle = 0.0001; else if (Math.abs(rotatableAngle) - Math.PI <= 0.0001) rotatableAngle = rotatableAngle - 0.0001; let rotatableDistance = this.distance; if (Math.abs(rotatableDistance) <= 0.0001) rotatableDistance = 0.0001; // fix distance for orthogonal-camera if (this.ortho > 0) { rotatableDistance = MathUtils.lerp(rotatableDistance, Math.max(rotatableDistance, 300), Math.pow(this.ortho, 8)); } // calculate rotationVector let rotationVector = new Vector3(Math.sin(this.rotation), 0, -Math.cos(this.rotation)); // 0 is towards north let angleRotationAxis = new Vector3(0, 1, 0).cross(rotationVector); rotationVector.applyAxisAngle(angleRotationAxis, (Math.PI / 2) - rotatableAngle); rotationVector.multiplyScalar(rotatableDistance); // position camera this.camera.rotation.set(0, 0, 0); this.camera.position.copy(this.position).sub(rotationVector); this.camera.lookAt(this.position); this.camera.rotateZ(this.tilt + rotatableAngle < 0 ? Math.PI : 0); // optimize far/near planes if (this.ortho <= 0) { let near = MathUtils.clamp(rotatableDistance / 1000, 0.01, 1); let far = MathUtils.clamp(rotatableDistance * 2, Math.max(near + 1, 2000), rotatableDistance + 5000); if (far - near > 10000) near = far - 10000; this.camera.near = near; this.camera.far = far; } else if (this.angle === 0) { this.camera.near = 1; this.camera.far = rotatableDistance + 300; } else { this.camera.near = 1; this.camera.far = 100000; } // event dispatchEvent(this.mapViewer.events, "bluemapCameraMoved", { controlsManager: this, camera: this.camera }); } // if the position changed, update map to show new position if (this.mapViewer.map) { let triggerDistance = 1; if (valueChanged) { if (this.distance > 300) { triggerDistance = this.mapViewer.data.loadedLowresViewDistance * 0.5; } else { triggerDistance = this.mapViewer.data.loadedHiresViewDistance * 0.5; } } if ( this.lastMapUpdatePosition === null || this.lastMapUpdateDistance === null || Math.abs(this.lastMapUpdatePosition.x - this.position.x) >= triggerDistance || Math.abs(this.lastMapUpdatePosition.z - this.position.z) >= triggerDistance || (this.distance < 1000 && this.lastMapUpdateDistance >= 1000) ) { this.lastMapUpdatePosition = this.position.clone(); this.lastMapUpdateDistance = this.distance; this.mapViewer.loadMapArea(this.position.x, this.position.z); } } } /** * Triggers an interaction on the screen (map), e.g. a mouse-click * @param screenPosition {THREE.Vector2} - Clicked position on the screen (usually event.x, event.y) * @param data {object} - Custom event data that will be added to the interaction-event */ handleMapInteraction(screenPosition, data = {}) { this.mapViewer.handleMapInteraction(screenPosition, data); } isValueChanged() { return !( this.data.position.equals(this.lastPosition) && this.data.rotation === this.lastRotation && this.data.angle === this.lastAngle && this.distance === this.lastDistance && this.ortho === this.lastOrtho && this.data.tilt === this.lastTilt ); } resetValueChanged() { this.lastPosition.copy(this.data.position); this.lastRotation = this.data.rotation; this.lastAngle = this.data.angle; this.lastDistance = this.distance; this.lastOrtho = this.ortho; this.lastTilt = this.data.tilt; } /** * @returns {number} */ get ortho() { return this.camera.ortho; } /** * @param ortho {number} */ set ortho(ortho) { this.camera.ortho = ortho; } get distance() { return this.camera.distance; } set distance(distance) { this.camera.distance = distance; } /** @typedef ControlsLike {{ * start: function(controls: ControlsManager), * stop: function(), * update: function(deltaTime: number, map: Map) * }} /** * @param controls {ControlsLike} */ set controls(controls) { if (this._controls && this._controls.stop) this._controls.stop(); this._controls = controls; if (controls) this.data.controls = controls.data || null if (this._controls && this._controls.start) this._controls.start(this); } /** * @returns {ControlsLike} */ get controls() { return this._controls; } /** * @returns {MapViewer} */ get mapViewer() { return this._mapViewer; } /** * @param value {MapViewer} */ set mapViewer(value) { this._mapViewer = value; this.data.mapViewer = value.data; } /** * @returns {CombinedCamera} */ get camera() { return this._camera; } /** * @param value {CombinedCamera} */ set camera(value) { this._camera = value; this.data.camera = value.data; } /** * @returns {Vector3} */ get position() { return this.data.position; } /** * @param value {Vector3} */ set position(value) { this.data.position = value; } /** * @returns {number} */ get rotation() { return this.data.rotation; } /** * @param value {number} */ set rotation(value) { this.data.rotation = value; } /** * @returns {number} */ get angle() { return this.data.angle; } /** * @param value {number} */ set angle(value) { this.data.angle = value; } /** * @returns {number} */ get tilt() { return this.data.tilt; } /** * @param value {number} */ set tilt(value) { this.data.tilt = value; } }