HardYards/web/world/js/camera.js
m3ultra a41328a219 Add graybox yard, third-person camera and game shell
The yard's meshes are placeholders; its layout is not. Anchor positions, ground
heights, the garden rect and the sun direction are what the other lanes build
against, so Lane E's GLBs drop into named slots without anyone re-deriving them.

Sun sits in the north (Australian yard), which puts the house's yard-facing wall
in permanent backlight. That's correct, but the fascia line carries three of the
seven anchors, so sky fill is turned up to keep it readable rather than a void.

Camera collision resolves toward the geometry, not away from it: clamping up to
a comfort distance after the raycast is what put the camera 17cm inside the
north wall. Ground is handled by heightAt() instead of a raycast — exact, can't
be tunnelled, and free.

main.js runs a fixed-dt accumulator so sim modules only ever see FIXED_DT. That
is what lets selftest fast-forward a 90s storm and get the numbers the player
got. Nothing auto-runs on import; index.html calls boot().

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 21:33:42 +10:00

177 lines
6.4 KiB
JavaScript

/**
* 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);
},
};
}