/** * SHADES — third-person camera rig. Lane A owns this file. * * Shoulder-follow by default, orbit while the right mouse button is held. * Two things here are load-bearing for other lanes: * - `yaw` is what Lane D's WASD is relative to. * - the camera collides against world.solids, because the house sits on the * north edge of a 20 m yard and a naive follow cam walks straight through * the wall the moment the player stands with their back to it. */ import * as THREE from '../vendor/three.module.js'; /** Stand-off kept between the camera and whatever it collided with. */ const WALL_MARGIN = 0.25; /** Absolute floor on camera-to-head distance. Above the 0.1 near plane. */ const NEAR_FLOOR = 0.15; /** How far the camera stays above the terrain. */ const GROUND_CLEARANCE = 0.35; const DEFAULTS = { distance: 4.5, // meters behind the target height: 1.55, // look-at height on the player (roughly the head) shoulder: 0.55, // lateral offset — the "third person" of it pitch: -0.18, // radians, slightly down minPitch: -1.15, maxPitch: 0.55, yaw: 0.0, sensitivity: 0.0042, // radians per pixel followLambda: 9, // position smoothing rate (higher = stiffer) fov: 62, }; /** * @param {HTMLElement} domElement Element that receives the orbit drag. * @param {object} [opts] * @returns {import('./contracts.js').CameraRig} */ export function createCameraRig(domElement, opts = {}) { const cfg = { ...DEFAULTS, ...opts }; const object = new THREE.PerspectiveCamera(cfg.fov, 1, 0.1, 400); object.position.set(0, 3, 8); let yaw = cfg.yaw; let pitch = cfg.pitch; let dragging = false; let lastX = 0, lastY = 0; // Smoothed camera position. Seeded on the first update so the camera doesn't // fly in from the origin on frame one. const smoothed = new THREE.Vector3(); let seeded = false; const onContextMenu = (e) => e.preventDefault(); const onPointerDown = (e) => { if (e.button !== 2) return; // RMB only dragging = true; lastX = e.clientX; lastY = e.clientY; domElement.setPointerCapture?.(e.pointerId); }; const onPointerMove = (e) => { if (!dragging) return; yaw -= (e.clientX - lastX) * cfg.sensitivity; pitch -= (e.clientY - lastY) * cfg.sensitivity; pitch = Math.max(cfg.minPitch, Math.min(cfg.maxPitch, pitch)); lastX = e.clientX; lastY = e.clientY; }; const onPointerUp = (e) => { if (e.button !== 2) return; dragging = false; domElement.releasePointerCapture?.(e.pointerId); }; const onWheel = (e) => { cfg.distance = Math.max(2, Math.min(9, cfg.distance + Math.sign(e.deltaY) * 0.4)); }; domElement.addEventListener('contextmenu', onContextMenu); domElement.addEventListener('pointerdown', onPointerDown); domElement.addEventListener('pointermove', onPointerMove); domElement.addEventListener('pointerup', onPointerUp); domElement.addEventListener('wheel', onWheel, { passive: true }); const target = new THREE.Vector3(); const desired = new THREE.Vector3(); const dir = new THREE.Vector3(); const ray = new THREE.Raycaster(); ray.far = 20; /** @type {THREE.Object3D[]} */ let solids = []; /** @type {(x:number, z:number) => number} */ let groundAt = () => -Infinity; return { object, get yaw() { return yaw; }, set yaw(v) { yaw = v; }, get pitch() { return pitch; }, /** * Obstacle meshes the camera must not pass through. NOT the ground — see * setGround(). Raycasting a 4800-triangle terrain every frame to learn * something world.heightAt() answers in closed form is the kind of waste * that only shows up once four other systems are also running. */ setSolids(list) { solids = list ?? []; }, /** @param {(x:number, z:number) => number} fn Pass world.heightAt. */ setGround(fn) { groundAt = fn ?? (() => -Infinity); }, /** * @param {number} dt * @param {THREE.Vector3} targetPos Player feet position. */ update(dt, targetPos) { target.set(targetPos.x, targetPos.y + cfg.height, targetPos.z); // Orbit offset, then slide sideways for the over-the-shoulder framing. const cp = Math.cos(pitch); dir.set(Math.sin(yaw) * cp, -Math.sin(pitch), Math.cos(yaw) * cp).normalize(); desired.copy(target).addScaledVector(dir, cfg.distance); desired.x += Math.cos(yaw) * cfg.shoulder; desired.z -= Math.sin(yaw) * cfg.shoulder; // Collision: cast from the head toward where the camera wants to be and // pull in to the first thing in the way. if (solids.length) { const toCam = desired.clone().sub(target); const dist = toCam.length(); ray.set(target, toCam.divideScalar(dist || 1)); ray.far = dist; const hit = ray.intersectObjects(solids, true)[0]; if (hit) { // The wall wins. A comfortable stand-off distance is a preference; a // camera inside the house is a bug, so when the two disagree the // geometry gets the last word and the player eats a shoulder-filling // close-up instead. (Getting this backwards — clamping UP to a // minimum distance after the raycast — is what put the camera 17 cm // inside the north wall the first time round.) const safe = Math.min(dist, hit.distance - WALL_MARGIN); desired.copy(target).addScaledVector(ray.ray.direction, Math.max(NEAR_FLOOR, safe)); } } // Ground is handled analytically rather than by raycast: exact, can't be // tunnelled through, and free. desired.y = Math.max(desired.y, groundAt(desired.x, desired.z) + GROUND_CLEARANCE); if (!seeded) { smoothed.copy(desired); seeded = true; } // Exponential smoothing that is correct at any dt (not the usual // frame-rate-dependent lerp). const a = 1 - Math.exp(-cfg.followLambda * dt); smoothed.lerp(desired, a); object.position.copy(smoothed); object.lookAt(target); }, /** Keep the projection square with the canvas. */ resize(width, height) { object.aspect = width / Math.max(1, height); object.updateProjectionMatrix(); }, dispose() { domElement.removeEventListener('contextmenu', onContextMenu); domElement.removeEventListener('pointerdown', onPointerDown); domElement.removeEventListener('pointermove', onPointerMove); domElement.removeEventListener('pointerup', onPointerUp); domElement.removeEventListener('wheel', onWheel); }, }; }