UNPKG

@polygonjs/polygonjs

Version:

node-based WebGL 3D engine https://polygonjs.com

464 lines (438 loc) 16.2 kB
import {BaseEvent, Spherical, Vector3, Vector2, Euler, Camera} from 'three'; import {CoreMath} from '../../../core/math/_Module'; import {disableContextMenu, enableContextMenu} from '../../../core/DomUtils'; import {BaseCollisionHandler} from './BaseCollisionHandler'; import {CorePlayer} from '../../../core/player/Player'; import {isBooleanTrue} from '../../../core/Type'; interface TranslationData { direction: Vector3; } interface RotationData { direction: {x: number; y: number}; } interface RotationRange { min: number; max: number; } // const VIEWER_CALLBACK_NAME = 'mobile-nav'; interface MobileJoystickControlsDefaultParams { rotateSpeed: number; rotationRange: RotationRange; // translateSpeed: number; } export const DEFAULT_PARAMS: MobileJoystickControlsDefaultParams = { rotateSpeed: 1, rotationRange: {min: -Math.PI * 0.25, max: Math.PI * 0.25}, // translateSpeed: 0.1, }; const EVENT_CHANGE: BaseEvent<'change'> = {type: 'change'}; const tmpCameraUnproject = new Vector3(); const spherical = new Spherical(); export interface MobileJoystickControlsOptions { translateDomElement?: HTMLElement; jumpDomElement?: HTMLElement; runDomElement?: HTMLElement; } export class MobileJoystickControls extends BaseCollisionHandler { private translationData: TranslationData = { direction: new Vector3(), }; private rotationData: RotationData = { direction: {x: 0, y: 0}, }; private _boundMethods = { onRotateStart: this._onRotateStart.bind(this), onRotateMove: this._onRotateMove.bind(this), onRotateEnd: this._onRotateEnd.bind(this), onTranslateStart: this._onTranslateStart.bind(this), onTranslateMove: this._onTranslateMove.bind(this), onTranslateEnd: this._onTranslateEnd.bind(this), onJump: this._onJump.bind(this), onRunToggle: this._onRunToggle.bind(this), }; private _startCameraRotation = new Euler(); // private _velocity = new Vector3(); // private _element: HTMLElement; // private _translationSpeed = 4; private _rotationSpeed = DEFAULT_PARAMS.rotateSpeed; private _rotationRange: RotationRange = { min: DEFAULT_PARAMS.rotationRange.min, max: DEFAULT_PARAMS.rotationRange.max, }; // private _translationSpeed = DEFAULT_PARAMS.translateSpeed; private _azimuthalAngle: number = 0; private _translateDomElement: HTMLElement; private _translateDomElementRect: DOMRect; private _jumpDomElement: HTMLElement; private _runDomElement: HTMLElement; constructor( private _camera: Camera, private domElement: HTMLElement, private options: MobileJoystickControlsOptions, private player?: CorePlayer ) { super(); // this._element = this._viewer.domElement(); this._camera.rotation.order = 'ZYX'; // const clock = new Clock(); // this._viewer.registerOnBeforeRender(VIEWER_CALLBACK_NAME, () => { // const deltaTime = Math.min(0.1, clock.getDelta()); // this.update(deltaTime); // }); this._translateDomElement = this.options.translateDomElement || this._createTranslateDomElement(); this._runDomElement = this.options.runDomElement || this._createRunDomElement(); this._jumpDomElement = this.options.jumpDomElement || this._createJumpDomElement(); this._translateDomElementRect = this._translateDomElement.getBoundingClientRect(); this._addElements(); this._addEvents(); } dispose() { // this._viewer.unRegisterOnBeforeRender(VIEWER_CALLBACK_NAME); this._removeEvents(); this._removeElements(); this.updateElements(); } private _createTranslateDomElement() { const rect = this.domElement.getBoundingClientRect(); const minDim = Math.min(rect.width, rect.height); const size = Math.round(0.4 * minDim); const margin = Math.round(0.1 * minDim); const element = document.createElement('div'); element.id = 'MobileJoystickControls-translate'; element.style.width = `${size}px`; element.style.height = element.style.width; element.style.border = '1px solid black'; element.style.borderRadius = `${size}px`; element.style.position = 'absolute'; element.style.bottom = `${margin}px`; element.style.left = `${margin}px`; return element; } private _jumpDomElementSize() { const rect = this.domElement.getBoundingClientRect(); const minDim = Math.min(rect.width, rect.height); const size = Math.round(0.2 * minDim); const margin = Math.round(0.05 * minDim); return {size, margin}; } private _createJumpDomElement() { const {size, margin} = this._jumpDomElementSize(); const element = document.createElement('div'); element.id = 'MobileJoystickControls-jump'; element.style.width = `${size}px`; const height = Math.floor(size); element.style.height = `${height}px`; element.style.border = '1px solid black'; element.style.position = 'absolute'; element.style.bottom = `${2 * margin + parseInt(this._runDomElement.style.height)}px`; element.style.right = `${margin}px`; element.style.borderRadius = `${height}px`; return element; } private _createRunDomElement() { const element = document.createElement('div'); const rect = this.domElement.getBoundingClientRect(); const minDim = Math.min(rect.width, rect.height); const size = Math.round(0.2 * minDim); const margin = Math.round(0.05 * minDim); element.id = 'MobileJoystickControls-run'; element.style.width = `${size}px`; element.style.height = `${Math.floor(size)}px`; element.style.border = '1px solid black'; element.style.position = 'absolute'; element.style.bottom = `${margin}px`; element.style.right = `${margin}px`; // element.style.translate = `-50%`; return element; } private _addElements() { this.domElement.parentElement?.append(this._translateDomElement); this.domElement.parentElement?.append(this._jumpDomElement); this.domElement.parentElement?.append(this._runDomElement); } private _removeElements() { function _removeElement(element: HTMLElement) { element.parentElement?.removeChild(element); } if (!this.options.translateDomElement) { _removeElement(this._translateDomElement); } if (!this.options.runDomElement) { _removeElement(this._runDomElement); } if (!this.options.jumpDomElement) { _removeElement(this._jumpDomElement); } } updateElements() { if (!this.player) { return; } this._jumpDomElement.style.display = isBooleanTrue(this.player.jumpAllowed) ? 'block' : 'none'; this._runDomElement.style.display = isBooleanTrue(this.player.runAllowed) ? 'block' : 'none'; } private _addEvents() { disableContextMenu(); this.domElement.addEventListener('touchstart', this._boundMethods.onRotateStart); this.domElement.addEventListener('touchmove', this._boundMethods.onRotateMove); this.domElement.addEventListener('touchend', this._boundMethods.onRotateEnd); this._translateDomElement.addEventListener('touchstart', this._boundMethods.onTranslateStart); this._translateDomElement.addEventListener('touchmove', this._boundMethods.onTranslateMove); this._translateDomElement.addEventListener('touchend', this._boundMethods.onTranslateEnd); this._jumpDomElement.addEventListener('pointerdown', this._boundMethods.onJump); this._runDomElement.addEventListener('pointerdown', this._boundMethods.onRunToggle); } private _removeEvents() { // TODO: ideally the viewer should know that // its element should have no context menu callback // if no mobile controls is attached. So the viewer should be in control // of re-establishing the event enableContextMenu(); this.domElement.removeEventListener('touchstart', this._boundMethods.onRotateStart); this.domElement.removeEventListener('touchmove', this._boundMethods.onRotateMove); this.domElement.removeEventListener('touchend', this._boundMethods.onRotateEnd); this._translateDomElement.removeEventListener('touchstart', this._boundMethods.onTranslateStart); this._translateDomElement.removeEventListener('touchmove', this._boundMethods.onTranslateMove); this._translateDomElement.removeEventListener('touchend', this._boundMethods.onTranslateEnd); this._jumpDomElement.removeEventListener('pointerdown', this._boundMethods.onJump); this._runDomElement.removeEventListener('pointerdown', this._boundMethods.onRunToggle); } setRotationSpeed(speed: number) { this._rotationSpeed = speed; } setRotationRange(range: RotationRange) { this._rotationRange.min = range.min; this._rotationRange.max = range.max; } // setTranslationSpeed(speed: number) { // this._translationSpeed = speed; // } // // // ROTATE // // private vLeft = new Vector3(); private vRight = new Vector3(); private vTop = new Vector3(); private vBottom = new Vector3(); private angleY = 0; private angleX = 0; private _rotationStartPosition = new Vector2(); private _rotationMovePosition = new Vector2(); private _rotationDelta = new Vector2(); private _onRotateStart(event: TouchEvent) { this._startCameraRotation.copy(this._camera.rotation); const touch = this._getTouch(event, this.domElement); if (!touch) { return; } this._rotationStartPosition.set(touch.clientX, touch.clientY); // x pan for rotation y this.vLeft.set(-1, 0, 0.5); this.vRight.set(1, 0, 0.5); [this.vLeft, this.vRight].forEach((v) => { v.unproject(this._camera); this._camera.worldToLocal(v); }); this.angleY = this.vLeft.angleTo(this.vRight); // y pan for rotation x this.vTop.set(0, 1, 0.5); this.vBottom.set(0, -1, 0.5); [this.vTop, this.vBottom].forEach((v) => { v.unproject(this._camera); this._camera.worldToLocal(v); }); this.angleX = this.vTop.angleTo(this.vBottom); } private _onRotateMove(event: TouchEvent) { const touch = this._getTouch(event, this.domElement); if (!touch) { return; } this._rotationMovePosition.set(touch.clientX, touch.clientY); this._rotationDelta.copy(this._rotationMovePosition).sub(this._rotationStartPosition); // delta.normalize(); this.rotationData.direction.x = this._rotationDelta.x / this.domElement.clientWidth; this.rotationData.direction.y = this._rotationDelta.y / this.domElement.clientHeight; // rotateData.speed = delta.length(); this._rotateCamera(this.rotationData); } private _onRotateEnd() { this.rotationData.direction.x = 0; this.rotationData.direction.y = 0; } private _rotateCamera(rotationData: RotationData) { const INVERT_Y = true; const INVERT_X = INVERT_Y; let angleY = this.angleY * rotationData.direction.x * this._rotationSpeed; this._camera.rotation.y = this._startCameraRotation.y + (INVERT_Y ? -angleY : angleY); let angleX = this.angleX * rotationData.direction.y * this._rotationSpeed; // apply bound this._camera.rotation.x = CoreMath.clamp( this._startCameraRotation.x + (INVERT_X ? -angleX : angleX), this._rotationRange.min, this._rotationRange.max ); this._computeAzimuthalAngle(); this.dispatchEvent(EVENT_CHANGE); } private _computeAzimuthalAngle() { this._camera.updateMatrixWorld(); tmpCameraUnproject.set(0, 0, 1); this._camera.localToWorld(tmpCameraUnproject); tmpCameraUnproject.sub(this._camera.position); spherical.setFromVector3(tmpCameraUnproject); this._azimuthalAngle = spherical.theta; } // // // TRANSLATE // // private _startCameraPosition = new Vector3(); private _translationStartPosition = new Vector2(); private _translationMovePosition = new Vector2(); private _translationDelta = new Vector2(); private _onTranslateStart(event: TouchEvent) { this._startCameraPosition.copy(this._camera.position); const touch = this._getTouch(event, this._translateDomElement); if (!touch) { return; } this._translateDomElementRect = this._translateDomElement.getBoundingClientRect(); const elementCenterX = this._translateDomElementRect.left + this._translateDomElementRect.width * 0.5; const elementCenterY = this._translateDomElementRect.top + this._translateDomElementRect.height * 0.5; // this._translationStartPosition.set(touch.clientX, touch.clientY); this._translationStartPosition.set(elementCenterX, elementCenterY); } private _onTranslateMove(event: TouchEvent) { const touch = this._getTouch(event, this._translateDomElement); if (!touch) { return; } this._translationMovePosition.set(touch.clientX, touch.clientY); this._translationDelta.copy(this._translationMovePosition).sub(this._translationStartPosition); this.translationData.direction.x = (this._translationDelta.x / this._translateDomElementRect.width) * 0.5; this.translationData.direction.z = (-this._translationDelta.y / this._translateDomElementRect.height) * 0.5; this._updatePlayerTranslate(); this.dispatchEvent(EVENT_CHANGE); } private _onTranslateEnd() { this.translationData.direction.x = 0; this.translationData.direction.z = 0; this._updatePlayerTranslate(); } private _updatePlayerTranslate() { if (!this.player) { return; } const direction = this.translationData.direction; this.player.setForward(false); this.player.setBackward(false); this.player.setLeft(false); this.player.setRight(false); const absx = Math.abs(direction.x); const absz = Math.abs(direction.z); const delta = absz - absx; function checkZ(player: CorePlayer) { if (direction.z > 0) { player.setForward(true); } if (direction.z < 0) { player.setBackward(true); } } function checkX(player: CorePlayer) { if (direction.x > 0) { player.setRight(true); } if (direction.x < 0) { player.setLeft(true); } } if (delta > 0) { // if delta > 0, we have dir.z > dir.x, // and we therefore move in z first checkZ(this.player); if (delta < absz * 0.5) { // if dir.x and dir.z are close, we move in diagonal checkX(this.player); } } else { checkX(this.player); if (delta < absx * 0.5) { // if dir.x and dir.z are close, we move in diagonal checkZ(this.player); } } } private _onJump() { this.player?.jump(); } private _onRunToggle() { if (!this.player) { return; } const runState = this.player.running(); this.player.setRun(!runState); const borderSize = this.player.running() ? 3 : 1; this._runDomElement.style.border = `${borderSize}px solid black`; } update(delta: number) { // this._translateCamera(this.translationData, delta); if (this.player) { this.player.setAzimuthalAngle(this._azimuthalAngle); this.player.update(delta); } } // private _camTmpPost = new Vector3(); // private _camWorldDir = new Vector3(); // private _up = new Vector3(0, 1, 0); // private _camSideVector = new Vector3(); // private _translateCamera(data: TranslationData, deltaTime: number) { // this._camera.getWorldDirection(this._camWorldDir); // this._camWorldDir.y = 0; // this._camWorldDir.normalize(); // this._camSideVector.crossVectors(this._up, this._camWorldDir); // this._camSideVector.normalize(); // this._camSideVector.multiplyScalar(-data.direction.x); // this._camWorldDir.multiplyScalar(data.direction.y); // this._velocity.copy(this._camWorldDir); // this._velocity.add(this._camSideVector); // const initialHeight = this._camera.position.y; // this._camTmpPost.copy(this._camera.position); // if (this._playerCollisionController) { // // damping // const damping = 1; //Math.exp(-3 * deltaTime) - 1; // this._velocity.addScaledVector(this._velocity, damping); // const deltaPosition = this._velocity.clone().multiplyScalar(deltaTime); // this._camTmpPost.add(deltaPosition); // const result = this._playerCollisionController.testPosition(this._camTmpPost); // if (result) { // // playerCollider.translate( result.normal.multiplyScalar( result.depth ) ); // this._camTmpPost.add(result.normal.multiplyScalar(result.depth)); // } // this._camera.position.copy(this._camTmpPost); // } else { // this._camTmpPost.add(this._camSideVector); // this._camTmpPost.add(this._camWorldDir); // this._camera.position.copy(this._camTmpPost); // } // // ensure that the camera never changes y. // this._camera.position.y = initialHeight; // } // // // UTILS // // private _getTouch(event: TouchEvent, element: HTMLElement) { for (let i = 0; i < event.touches.length; i++) { const touch = event.touches[i]; if (touch.target === element) { return touch; } } } }