import type { Scene } from "../Scene"; import { Camera, type CameraOptions } from "./Camera"; export type FreeCameraControls = { element: HTMLElement; keyboard?: boolean; pointer?: boolean; controller?: boolean; speed?: number; sensitivity?: number; }; export type FreeCameraOptions = CameraOptions & { controls?: FreeCameraControls }; /** Spectator-style WASD/mouse/gamepad camera backed entirely by shared camera and transform rows. */ export class FreeCamera extends Camera { #controls?: FreeCameraControls; #keys = new Set(); #frame = 0; #last = 0; constructor(scene: Scene, options: FreeCameraOptions = {}) { super(scene, options); const cameraReady = this.ready; this.ready = cameraReady.then(() => { this.cameraRow()[19] = 2; if (options.controls) this.attachControls(options.controls); return this; }); } attachControls(controls: FreeCameraControls) { this.detachControls(); this.#controls = controls; if (controls.keyboard !== false) { addEventListener("keydown", this.#keyDown); addEventListener("keyup", this.#keyUp); } if (controls.pointer !== false) { controls.element.addEventListener("click", this.#lock); addEventListener("mousemove", this.#mouse); } this.#last = performance.now(); this.#frame = requestAnimationFrame(this.#update); return this; } detachControls() { const element = this.#controls?.element; if (element) element.removeEventListener("click", this.#lock); removeEventListener("keydown", this.#keyDown); removeEventListener("keyup", this.#keyUp); removeEventListener("mousemove", this.#mouse); cancelAnimationFrame(this.#frame); this.#frame = 0; this.#keys.clear(); this.#controls = undefined; } #keyDown = (event: KeyboardEvent) => this.#keys.add(event.code); #keyUp = (event: KeyboardEvent) => this.#keys.delete(event.code); #lock = () => this.#controls?.element.requestPointerLock?.(); #mouse = (event: MouseEvent) => { if (!this.#controls || document.pointerLockElement !== this.#controls.element) return; const sensitivity = this.#controls.sensitivity ?? 0.002; this.cameraRow()[13] -= event.movementX * sensitivity; this.cameraRow()[14] = Math.min(1.55, Math.max(-1.55, this.cameraRow()[14] - event.movementY * sensitivity)); this.#writeRotor(); }; #writeRotor() { const yaw = this.cameraRow()[13]; const pitch = this.cameraRow()[14]; const sy = Math.sin(yaw / 2), cy = Math.cos(yaw / 2); const sx = Math.sin(pitch / 2), cx = Math.cos(pitch / 2); this.setRotor([sx * cy, cx * sy, -sx * sy, cx * cy]); } #update = (time: number) => { if (!this.#controls) return; const delta = Math.min(0.1, (time - this.#last) / 1000); this.#last = time; let x = Number(this.#keys.has("KeyD")) - Number(this.#keys.has("KeyA")); let y = Number(this.#keys.has("Space")) - Number(this.#keys.has("ControlLeft")); let z = Number(this.#keys.has("KeyW")) - Number(this.#keys.has("KeyS")); if (this.#controls.controller) { const pad = navigator.getGamepads?.().find(Boolean); if (pad) { x += pad.axes[0] ?? 0; y -= pad.axes[3] ?? 0; z -= pad.axes[1] ?? 0; } } const speed = (this.#controls.speed ?? 4) * delta; const yaw = this.cameraRow()[13]; const position = this.position; this.setPosition([ position.x + (x * Math.cos(yaw) + z * Math.sin(yaw)) * speed, position.y + y * speed, position.z + (x * -Math.sin(yaw) + z * Math.cos(yaw)) * speed, ]); this.#frame = requestAnimationFrame(this.#update); }; override async dispose() { this.detachControls(); await super.dispose(); } }