threepipe
Version:
A modern 3D viewer framework built on top of three.js, written in TypeScript, designed to make creating high-quality, modular, and extensible 3D experiences on the web simple and enjoyable.
352 lines (217 loc) • 9.54 kB
text/typescript
import {EventDispatcher, MathUtils, Object3D, Spherical, Vector3} from 'three'
import {IEvent, now, serialize} from 'ts-browser-helpers'
import {uiInput, uiPanelContainer, uiToggle} from 'uiconfig.js'
import {ICameraControls, ICameraControlsEventMap} from '../../core'
// eslint-disable-next-line @typescript-eslint/naming-convention
const _lookDirection = new Vector3()
// eslint-disable-next-line @typescript-eslint/naming-convention
const _spherical = new Spherical()
// eslint-disable-next-line @typescript-eslint/naming-convention
const _target = new Vector3()
// eslint-disable-next-line @typescript-eslint/naming-convention
const _changeEvent: IEvent<'change'> = {type: 'change'}
// todo bug - this is not showing in the UI. To test, switch to threeFirstPerson controlsMode for Default Camera in the tweakpane editor
export class FirstPersonControls2 extends EventDispatcher<ICameraControlsEventMap> implements ICameraControls {
readonly object: Object3D
readonly domElement: HTMLElement | Document
// API
enabled = true
enableKeys = true
movementSpeed = 1.0
lookSpeed = 0.005
lookVertical = true
autoForward = false
activeLook = true
heightSpeed = false
heightCoef = 1.0
heightMin = 0.0
heightMax = 1.0
constrainVertical = false
verticalMin = 0
verticalMax = Math.PI
mouseDragOn = false
// internals
autoSpeedFactor = 0.0
pointerX = 0
pointerY = 0
moveForward = false
moveBackward = false
moveLeft = false
moveRight = false
moveUp = false
moveDown = false
viewHalfX = 0
viewHalfY = 0
// private variables
// eslint-disable-next-line @typescript-eslint/naming-convention
private lat = 0
// eslint-disable-next-line @typescript-eslint/naming-convention
private lon = 0
constructor(object: Object3D, domElement: HTMLElement|Document) {
super()
this.object = object
this.domElement = domElement
this.onPointerMove = this.onPointerMove.bind(this)
this.onPointerDown = this.onPointerDown.bind(this)
this.onPointerUp = this.onPointerUp.bind(this)
this.onKeyDown = this.onKeyDown.bind(this)
this.onKeyUp = this.onKeyUp.bind(this)
this.onContextMenu = this.onContextMenu.bind(this)
this.domElement.addEventListener('contextmenu', this.onContextMenu)
;(this.domElement as HTMLElement).addEventListener('pointermove', this.onPointerMove)
;(this.domElement as HTMLElement).addEventListener('pointerdown', this.onPointerDown)
;(this.domElement as HTMLElement).addEventListener('pointerup', this.onPointerUp)
window.addEventListener('keydown', this.onKeyDown)
window.addEventListener('keyup', this.onKeyUp)
this.handleResize()
this.setOrientation()
}
setOrientation() {
const quaternion = this.object.quaternion
_lookDirection.set(0, 0, -1).applyQuaternion(quaternion)
_spherical.setFromVector3(_lookDirection)
this.lat = 90 - MathUtils.radToDeg(_spherical.phi)
this.lon = MathUtils.radToDeg(_spherical.theta)
}
handleResize() {
if (this.domElement === document) {
this.viewHalfX = window.innerWidth / 2
this.viewHalfY = window.innerHeight / 2
} else {
this.viewHalfX = (this.domElement as HTMLElement).offsetWidth / 2
this.viewHalfY = (this.domElement as HTMLElement).offsetHeight / 2
}
}
onPointerDown(event: PointerEvent) {
if (this.domElement !== document) {
(this.domElement as HTMLElement).focus()
}
if (this.activeLook) {
switch (event.button) {
case 0: this.moveForward = true; break
case 2: this.moveBackward = true; break
default: break
}
}
this.mouseDragOn = true
}
onPointerUp(event: PointerEvent) {
if (this.activeLook) {
switch (event.button) {
case 0: this.moveForward = false; break
case 2: this.moveBackward = false; break
default: break
}
}
this.mouseDragOn = false
}
onPointerMove(event: PointerEvent) {
if (this.domElement === document) {
this.pointerX = event.pageX - this.viewHalfX
this.pointerY = event.pageY - this.viewHalfY
} else {
this.pointerX = event.pageX - (this.domElement as HTMLElement).offsetLeft - this.viewHalfX
this.pointerY = event.pageY - (this.domElement as HTMLElement).offsetTop - this.viewHalfY
}
}
onKeyDown(event: KeyboardEvent) {
if (!this.enableKeys) return
switch (event.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 'KeyR': this.moveUp = true; break
case 'KeyF': this.moveDown = true; break
default: break
}
}
onKeyUp(event: KeyboardEvent) {
if (!this.enableKeys) return
switch (event.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 'KeyR': this.moveUp = false; break
case 'KeyF': this.moveDown = false; break
default: break
}
}
lookAt(x: number|Vector3, y?: number, z?: number) {
if ((x as Vector3).isVector3) {
_target.copy(x as Vector3)
} else {
if (y === undefined || z === undefined) console.error('FirstPersonControls2.lookAt: y and z parameters are required')
else _target.set(x as number, y, z)
}
this.object.lookAt(_target)
this.setOrientation()
return this
}
// eslint-disable-next-line @typescript-eslint/naming-convention
private targetPosition = new Vector3()
private _lastTime = -1 // in ms
update() {
const time = now() // in ms
const delta = (this._lastTime < 0 ? 16 : Math.min(time - this._lastTime, 1000)) / 1000 // in secs
this._lastTime = time
// console.log(delta)
if (!this.enabled) return
if (this.heightSpeed) {
const y = MathUtils.clamp(this.object.position.y, this.heightMin, this.heightMax)
const heightDelta = y - this.heightMin
this.autoSpeedFactor = delta * (heightDelta * this.heightCoef)
} else {
this.autoSpeedFactor = 0.0
}
const actualMoveSpeed = delta * this.movementSpeed
if (this.moveForward || this.autoForward && !this.moveBackward) this.object.translateZ(-(actualMoveSpeed + this.autoSpeedFactor))
if (this.moveBackward) this.object.translateZ(actualMoveSpeed)
if (this.moveLeft) this.object.translateX(-actualMoveSpeed)
if (this.moveRight) this.object.translateX(actualMoveSpeed)
if (this.moveUp) this.object.translateY(actualMoveSpeed)
if (this.moveDown) this.object.translateY(-actualMoveSpeed)
let actualLookSpeed = delta * this.lookSpeed
if (!this.activeLook) {
actualLookSpeed = 0
}
let verticalLookRatio = 1
if (this.constrainVertical) {
verticalLookRatio = Math.PI / (this.verticalMax - this.verticalMin)
}
this.lon -= this.pointerX * actualLookSpeed
if (this.lookVertical) this.lat -= this.pointerY * actualLookSpeed * verticalLookRatio
this.lat = Math.max(-85, Math.min(85, this.lat))
let phi = MathUtils.degToRad(90 - this.lat)
const theta = MathUtils.degToRad(this.lon)
if (this.constrainVertical) {
phi = MathUtils.mapLinear(phi, 0, Math.PI, this.verticalMin, this.verticalMax)
}
const position = this.object.position
this.targetPosition.setFromSphericalCoords(1, phi, theta).add(position)
this.object.lookAt(this.targetPosition)
this.dispatchEvent(_changeEvent)
}
dispose() {
this.domElement.removeEventListener('contextmenu', this.onContextMenu)
;(this.domElement as HTMLElement).removeEventListener('pointerdown', this.onPointerDown)
;(this.domElement as HTMLElement).removeEventListener('pointermove', this.onPointerMove)
;(this.domElement as HTMLElement).removeEventListener('pointerup', this.onPointerUp)
window.removeEventListener('keydown', this.onKeyDown)
window.removeEventListener('keyup', this.onKeyUp)
}
onContextMenu(event: Event) {
if (!this.enableKeys) return
event.preventDefault()
}
}