@polygonjs/polygonjs
Version:
node-based WebGL 3D engine https://polygonjs.com
464 lines (438 loc) • 16.2 kB
text/typescript
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;
}
}
}
}