HardYards/web/world/js/camera.js
type-two 701403ab1d Sprint 13 gate 2.5: two camera fixes — spawn framing, aftermath cloth-swallow
SPAWN FRAMING. The QA pass: "a pole dead-centre through the player on every
single first impression." Measured, not eyeballed — player spawns at (0,6)
looking up the yard, and post p3 stands at (0,7), one metre behind, dead on the
view line. spawnYawFor() sweeps outward from the ideal frame (camera opposite
the bed, so the player stands in front of what they protect) and takes the
first yaw that clears every obstacle. Per YARD, off site data: site_02 spawns
elsewhere with its posts elsewhere, so a hand-tuned constant would frame the
corner block by luck.

Two numbers here are measured and both were wrong first. Clearance 0.6, not 1.1
— my first pass asked 1.1m and produced a WORSE frame than the bug, swinging
105° to stare at a fence, because the greatest distance any obstacle can have
from the camera->head line is its own distance from the player, and p3 is only
1m away, so 1.1 was unreachable by construction. maxOff 90° — a frame turned
more than a quarter-circle off the bed is no longer a frame of the bed, so the
"roomiest" fallback is bounded to the arc. The assert reproduces the 105° from
the real yard and pins that the cap brings it to 68°.

AFTERMATH CLOTH-SWALLOW. "The dead draped sail can swallow the camera whole."
The camera has collided with the house since Sprint 2; the sail was just never
in the list. While up it hangs above head height and nothing notices — but a
FAILED sail lies in the yard at head height, the one moment you most want to
look at it. refreshCameraSolids() adds the cloth to the set, called from both
rebuilds (loadSiteInto makes new world.solids, rigSail makes a new cloth; either
alone strands the camera on a disposed mesh). Its bounding sphere recomputes
every frame, so the raycast reads live geometry. Verified live: camera solids
9 -> 10 the moment a sail rigs, and the cloth is in the set.

Added camera.solids getter so that was checkable at all — "did the sail join
the set" was otherwise a claim with no seam. And hoisted sailView's declaration
with rig/rigging: loadSiteInto's boot-time call reads it before any cloth
exists, which is a TDZ throw (page goes blank) that the same block already
documents for rig/rigging.

selftest 339/0/0 (was 338).
2026-07-18 01:18:29 +10:00

279 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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