import { Object3D, PerspectiveCamera, Quaternion, Vector2, Vector3, } from 'three'; import { clamp } from 'three/src/math/MathUtils'; import { deg2rad } from '../../common/convert'; export class ThirdPersonCamera { private currentPosition = new Vector3(); private currentLookAt = new Vector3(); private offsetFromPlayer = new Vector3(); private mousePos = new Vector2(); private prevMousePos = new Vector2(); private angleAroundPlayer = 180; private pitch = -45; private distance = 3; private panning = false; private pinching = false; private previousPinchLength = 0; constructor( private camera: PerspectiveCamera, private target: Object3D, private eventTarget: HTMLElement, ) {} private dragEvent = (x: number, y: number) => { this.prevMousePos.copy(this.mousePos); this.mousePos = this.mousePos.fromArray([x, y]); if (this.panning) { const offset = this.prevMousePos.clone().sub(this.mousePos); this.angleAroundPlayer = this.angleAroundPlayer + ((offset.x * 0.3) % 360); this.pitch = clamp(this.pitch + offset.y * 0.3, -90, 90); this.calculateCameraOffset(); } }; events = { contextmenu: (e: MouseEvent) => e.preventDefault(), mousedown: (e: MouseEvent) => { if (e.button === 2) { this.panning = true; } }, mouseup: (e: MouseEvent) => { if (e.button === 2) { this.panning = false; } }, mouseleave: (e: MouseEvent) => { this.panning = false; }, mousemove: (e: MouseEvent) => this.dragEvent(e.clientX, e.clientY), wheel: (e: WheelEvent) => { e.deltaY < 0 ? (this.distance /= 1.2) : (this.distance *= 1.2); this.distance = clamp(this.distance, 4, 20); this.calculateCameraOffset(); }, // mobile touchstart: (ev: TouchEvent) => { ev.preventDefault(); const touch = ev.touches[0] || ev.changedTouches[0]; this.mousePos.fromArray([touch.pageX, touch.pageY]); this.panning = true; if (ev.touches.length === 2) { this.pinching = true; } }, touchmove: (ev: TouchEvent) => { ev.preventDefault(); if (ev.touches.length === 2 && this.pinching) { const pinchLength = Math.hypot( ev.touches[0].pageX - ev.touches[1].pageX, ev.touches[0].pageY - ev.touches[1].pageY, ); if (this.previousPinchLength) { const delta = pinchLength / this.previousPinchLength; delta > 0 ? (this.distance *= delta) : (this.distance /= delta); this.distance = clamp(this.distance, 4, 20); this.calculateCameraOffset(); } this.previousPinchLength = pinchLength; } else { this.dragEvent(ev.touches[0].clientX, ev.touches[0].clientY); } }, touchend: (ev: TouchEvent) => { this.pinching = false; this.previousPinchLength = 0; if (!ev.touches?.length) { this.panning = false; } }, }; initialize() { Object.keys(this.events).forEach((key) => { this.eventTarget.addEventListener(key, this.events[key]); }); this.calculateCameraOffset(); } dispose() { Object.keys(this.events).forEach((key) => { this.eventTarget.removeEventListener(key, this.events[key]); }); } update(dt: number) { const offset = this.getTargetOffset(); const lookAt = this.getTargetLookAt(); // https://www.youtube.com/watch?v=UuNPHOJ_V5o const factor = 1.0 - Math.pow(0.001, dt); this.currentPosition.lerp(offset, factor); this.currentLookAt.lerp(lookAt, factor); this.camera.position.copy(this.currentPosition); this.camera.lookAt(this.currentLookAt); } private calculateCameraOffset() { const hdist = this.distance * Math.cos(deg2rad(this.pitch)); const vdist = this.distance * Math.sin(deg2rad(this.pitch)); this.offsetFromPlayer.set( hdist * Math.sin(deg2rad(this.angleAroundPlayer)), -vdist, hdist * Math.cos(deg2rad(this.angleAroundPlayer)), ); } private getTargetOffset(): Vector3 { const offset = this.offsetFromPlayer.clone(); const quat = new Quaternion(); this.target.getWorldQuaternion(quat); offset.applyQuaternion(quat); offset.add(this.target.position); return offset; } private getTargetLookAt(): Vector3 { const offset = new Vector3(0, 1.5, 0.5); const quat = new Quaternion(); this.target.getWorldQuaternion(quat); offset.applyQuaternion(quat); offset.add(this.target.position); return offset; } }