icy3dw/src/client/object/camera.ts

166 lines
4.5 KiB
TypeScript

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;
}
}