/** * 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'; /** * Perpendicular distance in XZ from point `p` to the segment a→b. The camera * cares about the whole segment, not the endpoints: a post the camera stands * clear of can still be planted squarely between it and the player's head. */ function distToSegmentXZ(p, a, b) { const abx = b.x - a.x, abz = b.z - a.z; const len2 = abx * abx + abz * abz; let t = len2 ? ((p.x - a.x) * abx + (p.z - a.z) * abz) / len2 : 0; t = t < 0 ? 0 : t > 1 ? 1 : t; return Math.hypot(p.x - (a.x + abx * t), p.z - (a.z + abz * t)); } /** * The opening yaw: point the camera so the first frame is player + what matters, * with nothing skewered through the middle of it. (SPRINT13 gate 2.5.) * * The QA pass put it plainly: "the boot camera puts a pole dead-centre through * the player on every single first impression". Measured rather than eyeballed — * the player spawns at (0,6) on backyard_01 looking up the yard at yaw 0, which * parks the camera near z≈10.4, and post p3 stands at (0,7). Dead between the * camera and the head, every boot, on a public URL. * * Two things this is NOT: * · not a magic yaw. A hand-picked angle is one site's answer, and sites are * data now — site_02 spawns somewhere else with its posts somewhere else, and * a constant tuned against the backyard would frame the corner block by luck. * · not the camera's existing collision. That pulls IN to the first solid in * the way, so a post behind the player trades a skewer for a shoulder-filling * close-up. Both are the bad frame; this picks a different angle instead. * * Sweeps outward from the ideal (camera opposite `lookAt`, so the player stands * in front of the thing they're here to protect) and takes the FIRST yaw with * real clearance — nearest the ideal wins, so the framing gives up as little as * it can. * * TWO NUMBERS HERE ARE MEASURED, not chosen, and both were wrong first: * * `clearance` 0.6 — my first pass asked for 1.1 m and produced a WORSE frame * than the bug it fixed: the camera swung 105° off the garden and stared at a * fence. Reason, measured in the running game: p3 stands at (0,7) and the player * spawns at (0,6), so the post is **1.0 m away** — and the greatest perpendicular * distance any obstacle can ever have from the camera→head line is its own * distance from the player. 1.1 was unreachable by construction. The sweep * dutifully tried all 24 candidates, failed every one, and fell back to * "roomiest", which is a rule that knows nothing about the garden. Clearance is * `sin(off) × 1.0 m` here, so 0.6 buys ~37° of turn and leaves the bed 23° off * centre in a 62° FOV: in frame, next to the player, no pole through the head. * * `maxOff` 90° — the fallback's bound, and the lesson from the same failure. A * frame that has turned more than a quarter-circle off the bed is no longer a * frame of the bed, so it cannot be the "best available" answer no matter how * roomy it is. Past this, a pole in shot is the lesser evil, and the sweep says * so by returning the roomiest angle WITHIN the arc rather than outside it. * * Never throws, never returns NaN: a yard boxed in on every side still gets its * best available frame. * * @param {{x,z}} player * @param {{x,z}} lookAt what the frame should be about — the garden bed * @param {{x,z}[]} obstacles vertical things, by XZ: posts, trunks, structures * @param {object} [opts] * @returns {number} yaw in radians */ export function spawnYawFor(player, lookAt, obstacles = [], opts = {}) { const distance = opts.distance ?? DEFAULTS.distance; const clearance = opts.clearance ?? 0.6; const maxOff = opts.maxOff ?? Math.PI / 2; const steps = opts.steps ?? 24; const ideal = Math.atan2(player.x - lookAt.x, player.z - lookAt.z); const clearAt = (yaw) => { const cam = { x: player.x + Math.sin(yaw) * distance, z: player.z + Math.cos(yaw) * distance }; let clear = Infinity; for (const o of obstacles) clear = Math.min(clear, distToSegmentXZ(o, player, cam)); return clear; }; let best = ideal, bestClear = clearAt(ideal); if (bestClear >= clearance) return ideal; // Alternate outward — +7.5°, −7.5°, +15° … — so "first acceptable" also means // "least turned away from the bed". Bounded by maxOff: see above. for (let i = 1; i <= steps; i++) { const mag = (Math.ceil(i / 2) * maxOff) / Math.ceil(steps / 2); if (mag > maxOff) break; const yaw = ideal + (i % 2 ? mag : -mag); const clear = clearAt(yaw); if (clear >= clearance) return yaw; if (clear > bestClear) { bestClear = clear; best = yaw; } } return best; } /** 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 ?? []; }, /** * What the camera is currently colliding against. A read-only view (spread, * so a caller can't mutate the live array). SPRINT13: added so the * aftermath cloth-swallow fix is observable — "did the dead sail actually * join the solid set" was otherwise a claim with no way to check it. */ get solids() { return [...solids]; }, /** @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); }, }; }